CiviCRM Standalone: Newsletter Double Opt-in With Any Website

#CiviCRM #Newsletter #WordPress

Introduction

If you run a nonprofit or community organization, you probably want a newsletter signup on your website. And if you use CiviCRM as your CRM, you want new subscribers to land directly in CiviCRM — with proper double opt-in, no third-party newsletter service required.

CiviCRM Standalone (the new deployment option that runs without Drupal, WordPress, or Joomla as a host CMS) makes this both easier and trickier. Easier because your CRM is now a clean, independent service. Trickier because most of the existing tutorials assume CiviCRM is embedded inside a CMS, and many of the old URL paths simply do not work.

This guide walks through how to build a newsletter signup form on any external website — whether that is a WordPress site, a Hugo static site, a plain HTML page, or anything else — that talks to CiviCRM Standalone via its REST API. The form collects an email address, CiviCRM creates or finds the contact, subscribes them to your mailing group, and automatically sends a double opt-in confirmation email. I figured most of this out the hard way, so hopefully this saves you some time.

Prerequisites

Setting up CiviCRM

1. Create a Mailing Group

Go to Contacts → Manage Groups → New Group. Create a group (e.g., “Newsletter”) and set the Group Type to “Mailing List.”

Crucially, set the Visibility to “Public Pages.” This is easy to overlook. The MailingEventSubscribe API action — which handles the double opt-in flow — checks whether the target group is public. If the group visibility is set to “User and User Admin Only” (the default), the API will reject the subscription with a “Group is not Public” error. No helpful hint, just a flat refusal. So: set it to “Public Pages.”

2. Configure FormProcessor

FormProcessor is a CiviCRM extension that lets you define reusable API actions — essentially named procedures that bundle multiple steps together and expose them as a single API call. We will create one called newsletter_signup.

  1. Go to Administer → Automation → FormProcessors
  2. Click “Add Form Processor”
  3. Name it newsletter_signup (the machine name matters — it becomes the API action name)
  4. Add an input: name it email, type “String” (or “Email” if available), mark it as required
  5. Add an action to find or create a contact:
    • Action type: “Get or Create Contact by Email”
    • Map the email input to this action
  6. Add a second action for the subscription:
    • Action type: “MailingEventSubscribe” (or use a generic “API Call” action with entity MailingEventSubscribe and action create)
    • Set the email parameter to the email input
    • Set the group_id to your newsletter group’s ID (you can find the group ID in the URL when editing the group, or via the API Explorer)
    • Map the contact_id from the output of the previous “Get or Create Contact” action

Once saved, you can test it in the CiviCRM API Explorer (Support → Developer → API Explorer v4) by calling FormProcessor.newsletter_signup with a test email. If everything works, CiviCRM will send a confirmation email to that address with a double opt-in link.

3. Find Your API Key and Site Key

API Key: Go to the contact record of the user you want to use for API access. Under the API Key tab, generate or copy the key. This user should have sufficient permissions to create contacts and manage group subscriptions.

Site Key: Open your CiviCRM Standalone’s civicrm.settings.php file on the server and look for:

define('CIVICRM_SITE_KEY', 'your-site-key-here');

Building the Form

Here is a complete, self-contained HTML/CSS/JS snippet that you can paste into any website. It calls the CiviCRM REST API directly from the browser using a plain XMLHttpRequest — no dependencies, no build step, no jQuery.

Replace the three placeholder variables at the top: CIVICRM_URL, API_KEY, and SITE_KEY.

<div id="newsletter-signup">
  <style>
    #newsletter-signup {
      max-width: 440px;
      margin: 2rem auto;
      padding: 2rem;
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 2px 16px rgba(0,0,0,0.10);
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    }
    #newsletter-signup h3 {
      margin: 0 0 0.5rem 0;
      font-size: 1.25rem;
    }
    #newsletter-signup p {
      margin: 0 0 1.2rem 0;
      color: #555;
      font-size: 0.95rem;
    }
    #newsletter-signup label {
      display: block;
      font-weight: 600;
      margin-bottom: 0.4rem;
      font-size: 0.95rem;
    }
    #newsletter-signup input[type="email"] {
      width: 100%;
      padding: 0.6rem 0.8rem;
      border: 1px solid #ccc;
      border-radius: 6px;
      font-size: 1rem;
      box-sizing: border-box;
      margin-bottom: 1rem;
    }
    #newsletter-signup input[type="email"]:focus {
      outline: none;
      border-color: #c0392b;
      box-shadow: 0 0 0 2px rgba(192,57,43,0.15);
    }
    #newsletter-signup button {
      width: 100%;
      padding: 0.7rem;
      background: #c0392b;
      color: #fff;
      border: none;
      border-radius: 6px;
      font-size: 1rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.2s;
    }
    #newsletter-signup button:hover {
      background: #a93226;
    }
    #newsletter-signup button:disabled {
      background: #ccc;
      cursor: not-allowed;
    }
    #newsletter-signup .nl-msg {
      margin-top: 1rem;
      padding: 0.8rem;
      border-radius: 6px;
      font-size: 0.95rem;
      display: none;
    }
    #newsletter-signup .nl-msg.success {
      background: #eafaf1;
      color: #1e7e34;
      border: 1px solid #b7e4c7;
      display: block;
    }
    #newsletter-signup .nl-msg.error {
      background: #fdecea;
      color: #a94442;
      border: 1px solid #f5c6cb;
      display: block;
    }
  </style>

  <h3>Subscribe to our Newsletter</h3>
  <p>Get updates directly in your inbox. We respect your privacy.</p>

  <form id="nl-form" onsubmit="return nlSubmit(event)">
    <label for="nl-email">Email Address</label>
    <input type="email" id="nl-email"
           placeholder="you@example.com" required>
    <button type="submit" id="nl-btn">Subscribe</button>
  </form>
  <div id="nl-msg" class="nl-msg"></div>

  <script>
    function nlSubmit(e) {
      e.preventDefault();

      // ===== REPLACE THESE THREE VALUES =====
      var CIVICRM_URL = "https://crm.example.com";
      var API_KEY     = "YOUR_API_KEY";
      var SITE_KEY    = "YOUR_SITE_KEY";
      // =======================================

      var email = document.getElementById("nl-email").value.trim();
      var btn   = document.getElementById("nl-btn");
      var msg   = document.getElementById("nl-msg");

      if (!email) return false;

      btn.disabled = true;
      btn.textContent = "Submitting...";
      msg.className = "nl-msg";
      msg.style.display = "none";

      var url = CIVICRM_URL
        + "/civicrm/ajax/rest"
        + "?entity=FormProcessor"
        + "&action=newsletter_signup"
        + "&json=1"
        + "&api_key=" + encodeURIComponent(API_KEY)
        + "&key=" + encodeURIComponent(SITE_KEY)
        + "&email=" + encodeURIComponent(email);

      var xhr = new XMLHttpRequest();
      xhr.open("GET", url, true);
      xhr.onreadystatechange = function() {
        if (xhr.readyState !== 4) return;
        btn.disabled = false;
        btn.textContent = "Subscribe";

        if (xhr.status === 200) {
          try {
            var resp = JSON.parse(xhr.responseText);
            if (resp.is_error && resp.is_error !== 0) {
              msg.textContent = "Error: " + (resp.error_message || "Unknown error");
              msg.className = "nl-msg error";
            } else {
              msg.textContent = "Thank you! Please check your inbox "
                + "and click the confirmation link to complete "
                + "your subscription (double opt-in).";
              msg.className = "nl-msg success";
              document.getElementById("nl-email").value = "";
            }
          } catch(ex) {
            msg.textContent = "Unexpected response from server.";
            msg.className = "nl-msg error";
          }
        } else {
          msg.textContent = "Request failed (HTTP " + xhr.status
            + "). Please try again later.";
          msg.className = "nl-msg error";
        }
      };
      xhr.send();
      return false;
    }
  </script>
</div>

This snippet is entirely self-contained. The styles are scoped to the #newsletter-signup wrapper so they will not interfere with your existing site styles. It renders as a clean card with a shadow, rounded corners, and a red submit button.

CORS Configuration

If your website (e.g., www.example.com) and CiviCRM (e.g., crm.example.com) are on different domains, the browser will block the AJAX request unless the CiviCRM server sends the appropriate CORS headers.

For Apache, add the following to the .htaccess file in your CiviCRM Standalone web root:

<IfModule mod_headers.c>
  Header set Access-Control-Allow-Origin "https://www.example.com"
  Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
  Header set Access-Control-Allow-Headers "Content-Type"
</IfModule>

If you need to allow multiple origins, you will need a more dynamic configuration using SetEnvIf or a rewrite rule. For a quick development setup you can use * as the origin, but in production always restrict it to your actual domain.

For nginx, you would use add_header directives in your server or location block:

location / {
    add_header Access-Control-Allow-Origin "https://www.example.com" always;
    add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type" always;

    # Handle preflight requests
    if ($request_method = OPTIONS) {
        return 204;
    }
}

After adding CORS headers, restart your web server and test again. Open the browser developer console and check that the request to crm.example.com no longer shows a CORS error.

WordPress Integration

If your public-facing website runs on WordPress, you have two options depending on whether you use a classic theme or a block theme.

Option A: Contact Form 7 + CiviCRM Integration Plugin (Classic Themes)

This approach works well with classic themes and gives you a form managed through the WordPress admin.

  1. Install and activate the Contact Form 7 plugin
  2. Install and activate the “Contact Form 7 CiviCRM Integration” plugin
  3. Create a new Contact Form 7 form with an email field
  4. In the CiviCRM Integration settings tab of the form, configure:
    • CiviCRM Host: https://crm.example.com
    • API Key: your API key
    • Site Key: your site key
    • REST Path: /civicrm/ajax/rest
  5. Set the action to use FormProcessor.newsletter_signup and map the email field

Important: Make sure the REST path is set to /civicrm/ajax/rest and not the legacy /sites/all/modules/civicrm/extern/rest.php or /core/extern/rest.php. CiviCRM Standalone does not have a civicrm.config.php file in the location that extern/rest.php expects, so hitting that endpoint will return an HTTP 500 error. The /civicrm/ajax/rest route goes through the main CiviCRM routing and works correctly with Standalone.

Option B: HTML Block in Block Editor (Block Themes)

If you use a modern block theme (or simply prefer not to install extra plugins), you can paste the HTML/JS snippet directly into a Custom HTML block in the WordPress block editor.

This works — with one significant caveat.

WordPress strips <script>, <form>, and <input> tags. WordPress has a security filter called kses that sanitizes HTML content. When you save a page through the block editor (which uses the REST API under the hood), kses runs and silently removes any tags it considers unsafe — including <script>, <form>, and often <input>. You will paste your snippet, hit Save, reload the page, and find that the form and JavaScript are simply gone.

The solution is to disable kses filtering for REST API requests. Create a small must-use plugin (mu-plugin) at wp-content/mu-plugins/disable-kses-rest.php:

<?php
/**
 * Plugin Name: Disable kses for REST API
 * Description: Prevents WordPress from stripping script/form/input tags
 *              when saving posts via the REST API (block editor).
 */

add_action('rest_api_init', function() {
    if (current_user_can('unfiltered_html')) {
        kses_remove_filters();
    }
});

This checks that the current user has the unfiltered_html capability (which administrators have by default) before disabling the filter. This way, only trusted users can save unfiltered HTML, and the restriction remains in place for lower-privileged roles.

After placing this file, go back to your page, paste the HTML/JS snippet into a Custom HTML block, and save. The form and script tags will be preserved this time.

Integration with Other CMS / Static Sites

The HTML/JS snippet from above is plain, dependency-free code. It works anywhere you can place HTML and JavaScript. Here are a few specific pointers:

The only thing you need is that the page is served over HTTPS (for secure API calls) and that CORS headers are configured on your CiviCRM server if the domains differ.

Troubleshooting / Common Pitfalls

Here are the issues I ran into, so you can skip straight to the fix.

extern/rest.php returns HTTP 500

If you call https://crm.example.com/core/extern/rest.php (or any extern/rest.php path) and get a 500 error, this is because CiviCRM Standalone does not place civicrm.config.php in the location that extern/rest.php tries to load it from. The fix is simple: use /civicrm/ajax/rest as the API endpoint instead. This is the modern, supported route for CiviCRM Standalone’s REST API.

“Group is not Public” error

When calling MailingEventSubscribe.create and you get this error, it means your target mailing group has its visibility set to “User and User Admin Only.” Go to Contacts → Manage Groups, edit the group, and change the Visibility to “Public Pages.” The MailingEventSubscribe API enforces this check because the double opt-in flow is intended for public signups.

CORS errors in browser console

If you see errors like Access to XMLHttpRequest at 'https://crm.example.com/...' from origin 'https://www.example.com' has been blocked by CORS policy, your CiviCRM server is not sending the right CORS headers. See the CORS Configuration section above. Remember that after adding the headers you need to restart Apache or nginx. Also note that browser caching can be aggressive with CORS preflight responses — do a hard refresh or test in an incognito window.

WordPress strips form/script tags

You paste the HTML snippet into a Custom HTML block, save, and the <script> and <form> tags vanish. This is WordPress’s kses sanitization. The block editor saves content via the REST API, and kses runs on REST API requests. Install the mu-plugin shown in the WordPress section above to disable kses for admin users on REST API requests.

API permission error on System.get

Some CiviCRM API integrations (e.g., the CF7 CiviCRM plugin) call System.get to validate the connection. This API action requires the “administer CiviCRM” permission. If your API user does not have this permission, the validation check will fail — even though the actual contact creation and group subscription operations would work fine without it. Either grant the API user the “administer CiviCRM” permission, or if security policy prevents that, know that the underlying newsletter signup will still work; it is just the connection test that fails.


That is it. Once everything is wired up, the flow is: visitor enters email → your form calls CiviCRM’s REST API → FormProcessor creates/finds the contact and subscribes them → CiviCRM sends a double opt-in confirmation email → visitor clicks the link → they are subscribed. No third-party newsletter service, full control over your data, and it works with any frontend.