Writing

CiviCRM Standalone: Newsletter Double Opt-in With Any Website

By Johannes Filter ·

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.

Heads-up: putting an API key in the browser is a tradeoff

The simplest version of this guide shows a form that calls the CiviCRM REST API directly from the browser, with the API key embedded in a <script> tag. That key is visible to anyone who reads the page source. Section 3 explains how to lock that user down so the leaked key cannot do much harm — it is reduced to triggering one specific FormProcessor and nothing else. But even with that lockdown, anyone with the key can fire off arbitrary subscribe-attempts, which means your CiviCRM sends your confirmation emails to addresses an attacker chose. That is a real abuse vector and a real risk to your sending reputation.

For anything beyond a small site or quick demo, prefer the server-side proxy described in Hardening below. That keeps the key out of the browser entirely and lets you validate origin, CAPTCHA tokens, and rate limits before forwarding to CiviCRM. The browser-direct path is shown here because it is what most existing material assumes, and because the lockdown in section 3 makes it acceptable for low-stakes setups — but acceptable is not ideal.

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. Set Up a Locked-Down API User

The form snippet in the next section ships your API key to every visitor’s browser. Anyone can copy it out of the page source and replay it with curl. So treat this user as already-compromised credentials and grant it the absolute minimum.

The goal: even if the key is exfiltrated, it can only trigger your newsletter_signup form processor — not Contact.get, not Contact.create directly, not any other API call.

This is achievable thanks to two FormProcessor properties:

  1. Each FormProcessor has a configurable Permission field — that single permission is the only thing required to invoke it (FormProcessor overrides the default administer CiviCRM requirement via the alterAPIPermissions hook).
  2. FormProcessor runs its internal sub-actions (“Get or Create Contact by Email”, “MailingEventSubscribe”) with system privileges. The calling user does not need add contacts or any other “real” CiviCRM permission for those sub-steps to succeed.

So the lockdown works like this:

  1. Create a dedicated contact and login user, separate from any human admin. In Standalone, create the login user without a usable password, so the account can only be reached via API key.

  2. Set a permission string on the FormProcessor — and, on Standalone, register it. Edit your newsletter_signup form processor and set its Permission field to something distinctive, e.g. newsletter signup api.

    Whether that string just works depends on your CiviCRM flavor, and this is the single biggest gotcha in the whole setup:

    • CiviCRM Standalone: the string must be a registered permission, or you cannot grant it at all. A Standalone role stores its permissions in a field whose allowed values are the pseudoconstant list of known permissions — core plus everything advertised through hook_civicrm_permission. When you save a role, APIv4 silently drops any permission string that is not on that list. So an unregistered custom string never lands on the role, the API user never holds it, and every call fails with API3 Request Authorization failed — even though the role looks correct in the UI. (Older CiviCRM was laxer, which is why this guide originally claimed “any string works.”)
    • Drupal / WordPress: the check itself accepts any string — CRM_Core_Permission_Drupal8::check() just delegates to the CMS’s hasPermission(), a plain string-membership test, and the CMS does not strip unknown permissions from a role. But an unadvertised string does not show up in the CMS permissions UI, so you can only grant it via code/drush/config, not by ticking a checkbox. Register it and it appears like any other permission.

    Either way, advertising the permission through hook_civicrm_permission is the clean fix. Two options:

    • Drop in formprocessorperms. A tiny extension that reads every form processor’s configured Permission string and registers them all automatically — install it once and any permission you type into any form processor becomes grantable. Tested across Standalone, Drupal 10/11, WordPress and Joomla.
    • Or roll your own one-hook extension:
      function myext_civicrm_permission(&$permissions) {
        $permissions['newsletter signup api'] = [
          'label' => 'Newsletter signup API',
          'description' => 'Invoke the newsletter_signup form processor.',
        ];
      }
      
      civix generate:module myext scaffolds it in seconds.

    See Alternatives to registering a permission below if you would rather sidestep this entirely.

  3. Grant the API user only these permissions (via Roles in Standalone, via the user record in other deployments):

    • authenticate with api key — required for API-key authentication (the authx extension) to actually log the user in. Without it the request runs as anonymous and fails authorization, no matter what else the role holds.
    • access AJAX API — required to use /civicrm/ajax/rest at all.
    • newsletter signup api — your custom string from step 2 (registered per that step).
  4. Do NOT grant any of these:

    • administer CiviCRM, access CiviCRM
    • add contacts, view all contacts, edit all contacts
    • access CiviMail subscribe/unsubscribe pages
    • any Contribution / Membership / Event permission
  5. Generate the API key. On the user’s contact record, open the API Key tab and generate it.

  6. Site Key. Open civicrm.settings.php on the server and copy the value of CIVICRM_SITE_KEY:

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

Verify the lockdown. With the same API key, try calling a different endpoint:

curl "https://crm.example.com/civicrm/ajax/rest?entity=Contact&action=get&json=1&api_key=THEKEY&key=THESITEKEY"

You should get back a permission-denied error. If you receive contact data, the user has more permissions than it should — go back to step 4.

What this still does NOT prevent. Even with this minimal setup, anyone holding the key can submit arbitrary email addresses to the form processor. Each submission triggers a confirmation email from your CiviCRM. A malicious actor can use this to spam strangers via your domain — bad for them and bad for your sending reputation. Permission lockdown is the floor, not the ceiling. See Hardening below for what to layer on top.

Alternatives to registering a permission

If you would rather not deal with permission registration at all, three ways around it:

That said, for most people the lowest-friction path is simply dropping in formprocessorperms (step 2) and moving on; the alternatives above matter mainly when you cannot install extensions or want the key off the client entirely.

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";

      // CiviCRM Standalone rejects GET for write actions ("Destructive HTTP GET"),
      // so we POST. Keys go in the body — they would otherwise end up in webserver
      // access logs as URL parameters.
      var body = "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("POST", CIVICRM_URL + "/civicrm/ajax/rest", true);
      xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
      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(body);
      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.

Hardening

The locked-down API user from section 3 is the minimum you should ship. On its own, it still lets anyone with the key fire off subscribe-attempts to arbitrary email addresses, which means your CiviCRM emits your confirmation emails to people the attacker picked. The three measures below close the remaining gap. The third one — a server-side proxy — is the one I would actually recommend for any production site; the first two are useful even on top of a proxy.

The right pattern for production is to never put the API key in the browser at all. Instead:

  1. The form on your public site posts to your own small endpoint (a WordPress REST endpoint, a Cloudflare Worker, a Netlify Function, or any tiny server you control).
  2. That endpoint stores the API key as a server-side environment variable.
  3. The endpoint validates the CAPTCHA token, optionally checks the request origin, applies its own rate limit, and then calls CiviCRM.

This eliminates the leaked-key problem entirely. The trade-off is a small piece of backend code to maintain — but that is genuinely small (~50 lines of PHP / JS). For WordPress sites, the existing CF7-CiviCRM-Integration plugin already does exactly this: you embed a Contact Form 7 form, the plugin holds the API key in the WP options table server-side, and CiviCRM is called from the WP backend. That is a one-evening setup and gives you a server-side proxy without writing code.

Add a CAPTCHA

Drop in Cloudflare Turnstile (free, no captcha image) or hCaptcha. The widget produces a token that you verify server-side before forwarding to CiviCRM. This requires that server-side proxy from the previous section — there is no clean way to verify a CAPTCHA token from a browser-only setup.

Rate-limit /civicrm/ajax/rest

If your CiviCRM is behind Cloudflare, set up a rate-limiting rule (e.g. 5 requests per minute per IP for /civicrm/ajax/rest). On nginx, the same is achievable with limit_req_zone and limit_req directives. Pick a low number — legitimate users only submit the form once. This works regardless of whether you use the browser-direct path or a proxy.

Life after signup: unconfirmed subscriptions and list hygiene

Getting the form working is only half the job. The other half is what happens to the signups afterwards — especially the ones that never confirm. This part is barely documented, and it is where a working setup quietly rots.

What CiviCRM does (and does not do) with unconfirmed signups

Why this matters: spam list-bombing

A public subscribe form with no real bot protection is a magnet for list-bombing: bots submit thousands of other people’s addresses. Each submission makes your CiviCRM send your confirmation email to an address the bot picked — which torches your sending reputation — and leaves a Pending junk contact behind.

You can spot it instantly by the confirmation rate. Real humans confirm roughly half the time. In one real flood I had to clean up, a form produced about 2,400 subscribes with a ~1% confirmation rate — and of ~1,900 of those “subscribers”, exactly one had ever made a contribution. That is not a signup surge; that is a bot.

Prevention comes first

Cleaning up afterwards treats the symptom. Keep the junk out at the door:

A cleanup routine for stale Pending

Since nothing prunes them for you, build a repeatable routine with SearchKit — no code required:

  1. Search Group Contact where Group = your newsletter, Status = Pending, and the subscribe/added date is older than, say, 30 days.
  2. Tighten it so you only catch genuine dead weight: add “has no contributions” and “not Added in any other group”, so a real-but-slow confirmer or an otherwise-engaged contact is never swept up.
  3. Review the result, then run a bulk Remove from group (conservative — keeps the contact) or Delete contacts (for pure spam with no other value).

Run it by hand periodically, or automate it with a CiviRules daily trigger. Because it is a saved search, the criteria are auditable — far safer than a one-off DELETE.

An unconfirmed opt-in is arguably not consent. Holding those records indefinitely is hard to justify under the GDPR, so a periodic prune of never-confirmed Pending contacts is not just tidiness — it is good data-protection practice.

Beyond one list: multiple newsletters and per-brand senders

Everything above subscribes people to one group and sends the confirmation from one address — your CiviCRM domain default. That is exactly right for a single newsletter. But the moment you run two brands from one CiviCRM — say two associations, each with its own domain — the core MailingEventSubscribe flow hits a wall: it hardcodes the confirmation’s From to the domain default (CRM_Core_BAO_Domain::getNameAndEmail()), and the body is a single global “Subscribe” mailing component. Same sender, same template, for every list. You can differentiate the name of the list with a token, but not the sending domain or the branding.

If you need per-brand senders, branded confirmation emails, a preference center, or GDPR consent records, the tool built for this is SYSTOPIA’s Advanced Newsletter Management (de.systopia.newsletter, plus its hard dependency de.systopia.xcm). It replaces the FormProcessor + MailingEventSubscribe path with its own profiles: each profile binds a set of groups, a sender address, and its own opt-in / info / unsubscribe templates. One profile per brand and you are done — natively, no alterMailParams hook.

It is not a drop-in, though. It ships no public pages — only admin config. The entire public flow is API (NewsletterSubscription.submit / .request / .confirm), so you build the signup form and the confirmation landing page yourself, exactly like the core approach above. What you gain is the per-profile sender and templates; what you spend is a bit more wiring. Here are the sharp edges I hit — none of them are in the docs.

sender_email on the profile is an ID, not the address. On CiviCRM 6.x (which has the SiteEmailAddress entity) the profile resolves its sender by that entity’s id, not the "Name" <email> label. Store the label and the lookup silently fails and falls back to the domain default — so your second brand’s confirmation goes out from the first brand’s address, and nothing errors. Store the numeric id. And watch for the same address existing twice (e.g. info@… under two display names): resolve by email and display name, or you will pick the wrong one.

The default XCM profile creates duplicates. submit matches or creates the contact through XCM, and the out-of-the-box default XCM profile does not dedupe on email alone. Two signups with the same address — or an existing member signing up — produce a second contact each time. Configure a dedicated XCM profile that matches on email only (the EmailOnlyMatcher rule), with no field overwriting, and point every newsletter profile’s xcm_profile at it. A public signup must never mutate an existing contact’s data.

confirm is a two-argument dance and sends a second email. It needs contact_id and contact_checksum (the checksum is self-contained — {cid}_{hash} — so the contact id is the part before the first _), plus either a mailing_lists map of {group_id: 'Added'} or autoconfirm=1. And note: after confirming, it sends the profile’s info template. If you left that template empty (as the default profile does) your subscribers get a blank email that lands straight in spam. Brand the info template, or disable it.

Confirm on POST, never on GET. The confirmation link lands on your page; if that page confirms on page-load, mail-security scanners and link-preview bots will auto-confirm every subscriber for them. Show a button; confirm only on the explicit POST. And hardcode the profile name server-side per site — do not trust it from the query string, or someone can confirm their own contact against the wrong brand.

One upside against the earlier permission saga: this extension registers its own access Advanced Newsletter Management API permission through hook_civicrm_permission, so on Standalone it is grantable — it will not get silently stripped from the role. Grant that one permission to your locked-down API user for submit and confirm.

Finally, temper expectations on maturity: it is APIv3 and Smarty-2 (via the smarty-v2 compatibility mixin) and marked develStage=dev. It is actively maintained and widely deployed, but pin the extension and XCM to specific tags and re-test after any core upgrade. For a single newsletter the FormProcessor approach above is simpler and I would stay with it; reach for profiles when you genuinely have multiple senders to keep apart.

Troubleshooting / Common Pitfalls

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

“API3 Request Authorization failed” even though the role has the permission

You granted the permission, the role screen shows it, and calls still fail. On Standalone this almost always means the permission string is not registered: the role’s permissions field only accepts known permissions, so an unregistered custom string is silently dropped when the role is saved and never reaches the database. Check the raw value with SELECT permissions FROM civicrm_role WHERE id = <id>; if your string is missing, register it (see step 2 — formprocessorperms or your own hook_civicrm_permission) and re-save the role. Also confirm the role has authenticate with api key, or the request runs anonymously and fails the same way.

A handy debugging detail: the APIv3 REST endpoint names the exact missing permission (insufficient permission: require newsletter signup api), while the APIv4 endpoint deliberately anonymizes it to a bare HTTP 403 with an error ID. If you are stuck on a 403 from /civicrm/ajax/api4/…, replay the same call against /civicrm/ajax/rest (APIv3) to find out which permission it actually wants.

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.

“SECURITY: Destructive HTTP GET” error

If your request returns {"error_message":"SECURITY: All requests that modify the database must be http POST, not GET.","is_error":1}, you are sending a GET request to a write action. CiviCRM Standalone enforces POST for any API call that modifies the database — including MailingEventSubscribe.create and the FormProcessor that wraps it. The fix is to switch your XMLHttpRequest (or fetch) to POST and put the parameters in the request body, not in the URL. The snippet in the Building the Form section already does this; older versions of this guide used GET and would hit this error.

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. That action requires the administer CiviCRM permission. The locked-down API user from section 3 doesn’t have it — and shouldn’t. The connection test will fail; the actual newsletter signup will still work. Treat the failed test as expected and move on. If you absolutely need a green connection test, do it once with a temporary admin-level key, then swap in the locked-down key for production traffic.


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.