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.
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
- A working CiviCRM Standalone installation (e.g., at
crm.example.com) - The FormProcessor extension installed and enabled in CiviCRM (install via the Extensions screen or
cv ext:install formprocessor) - A dedicated CiviCRM user for API access — not your human admin account. We will lock this user down to the absolute minimum in section 3.
- Your Site Key (found in
civicrm.settings.php, theCIVICRM_SITE_KEYconstant)
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.
- Go to Administer → Automation → FormProcessors
- Click “Add Form Processor”
- Name it
newsletter_signup(the machine name matters — it becomes the API action name) - Add an input: name it
email, type “String” (or “Email” if available), mark it as required - Add an action to find or create a contact:
- Action type: “Get or Create Contact by Email”
- Map the email input to this action
- Add a second action for the subscription:
- Action type: “MailingEventSubscribe” (or use a generic “API Call” action with entity
MailingEventSubscribeand actioncreate) - Set the
emailparameter to the email input - Set the
group_idto 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_idfrom the output of the previous “Get or Create Contact” action
- Action type: “MailingEventSubscribe” (or use a generic “API Call” action with entity
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:
- Each FormProcessor has a configurable Permission field — that single permission is the only thing required to invoke it (FormProcessor overrides the default
administer CiviCRMrequirement via thealterAPIPermissionshook). - FormProcessor runs its internal sub-actions (“Get or Create Contact by Email”, “MailingEventSubscribe”) with system privileges. The calling user does not need
add contactsor any other “real” CiviCRM permission for those sub-steps to succeed.
So the lockdown works like this:
-
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.
-
Set a unique permission string on the FormProcessor. Edit your
newsletter_signupform processor and set its Permission field to something distinctive, e.g.newsletter signup api. Any string works — it does not need to be registered anywhere; it just needs to be unique to this one form processor. -
Grant the API user only two permissions (via Roles in Standalone, via the user record in other deployments):
access AJAX API— required to use/civicrm/ajax/restat all.newsletter signup api— your custom string from step 2.
-
Do NOT grant any of these:
administer CiviCRM,access CiviCRMadd contacts,view all contacts,edit all contactsaccess CiviMail subscribe/unsubscribe pages- any Contribution / Membership / Event permission
-
Generate the API key. On the user’s contact record, open the API Key tab and generate it.
-
Site Key. Open
civicrm.settings.phpon the server and copy the value ofCIVICRM_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.
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.
- Install and activate the Contact Form 7 plugin
- Install and activate the “Contact Form 7 CiviCRM Integration” plugin
- Create a new Contact Form 7 form with an email field
- 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
- CiviCRM Host:
- Set the action to use
FormProcessor.newsletter_signupand 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:
- Hugo, Jekyll, 11ty, or other static site generators:
Create a partial or include file (e.g.,
newsletter-form.html) with the snippet and include it in your page templates. In Hugo:{{ partial "newsletter-form.html" . }}. In Jekyll:{% include newsletter-form.html %}. In 11ty: use a Nunjucks include or shortcode. - Drupal:
Use a “Custom Block” with full HTML format, or add it directly in a Twig template.
Make sure your text format allows
<script>tags (the “Full HTML” format usually does). - Joomla: Use a Custom HTML module or a “Custom Code” article element. Again, ensure the editor does not strip script tags (switch to source/code view).
- Plain HTML site: Just paste it in. No special considerations.
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.
Server-Side Proxy (recommended for anything serious)
The right pattern for production is to never put the API key in the browser at all. Instead:
- 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).
- That endpoint stores the API key as a server-side environment variable.
- 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.
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.
“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.