CiviCRM Standalone: Newsletter Double Opt-In mit beliebiger Website

Einleitung

Wenn Sie eine Nonprofit- oder Community-Organisation betreiben, möchten Sie wahrscheinlich eine Newsletter-Anmeldung auf Ihrer Website haben. Und wenn Sie CiviCRM als CRM nutzen, sollen neue Abonnent:innen direkt in CiviCRM landen - mit sauberem Double-Opt-In und ohne zusätzlichen Newsletter-Dienst.

CiviCRM Standalone (die neue Deployment-Option, die ohne Drupal, WordPress oder Joomla als Host-CMS läuft) macht das gleichzeitig einfacher und schwieriger. Einfacher, weil das CRM jetzt ein sauber getrennter, eigenständiger Dienst ist. Schwieriger, weil die meisten vorhandenen Anleitungen davon ausgehen, dass CiviCRM in ein CMS eingebettet ist, und viele alte URL-Pfade schlicht nicht funktionieren.

Diese Anleitung zeigt, wie Sie ein Newsletter-Anmeldeformular auf jeder externen Website bauen - egal ob WordPress, statische Hugo-Seite, einfache HTML-Seite oder etwas anderes -, das über die REST API mit CiviCRM Standalone spricht. Das Formular sammelt eine E-Mail-Adresse ein, CiviCRM erstellt oder findet den Kontakt, trägt ihn in Ihre Mailing-Gruppe ein und verschickt automatisch eine Double-Opt-In-Bestätigungsmail. Ich habe mir das meiste davon auf die harte Tour zusammengesucht; hoffentlich spart Ihnen das etwas Zeit.

Vorab: API-Key im Browser ist ein Tradeoff

Die einfachste Variante dieser Anleitung zeigt ein Formular, das die CiviCRM-REST-API direkt aus dem Browser anspricht - der API-Key liegt sichtbar in einem <script>-Tag. Jede:r, der den Quelltext liest, hat ihn. Abschnitt 3 erklärt, wie man den User dahinter so eng zusammenschnürt, dass ein abgegriffener Key keinen großen Schaden anrichten kann - er reduziert sich darauf, einen einzigen FormProcessor auszulösen, sonst nichts. Aber selbst mit Lockdown kann jede:r mit dem Key beliebige Subscribe-Versuche absetzen, was bedeutet: Ihr CiviCRM verschickt Ihre Bestätigungsmails an Adressen, die ein:e Angreifer:in ausgewählt hat. Das ist ein realer Missbrauchsvektor und ein realer Reputationsrisiko für Ihren Mailversand.

Für alles, was über eine kleine Site oder einen schnellen Demo-Build hinausgeht, nehmen Sie lieber den Server-seitigen Proxy aus dem Abschnitt Hardening unten. Damit landet der Key gar nicht erst im Browser, und Sie können Origin, CAPTCHA-Token und Rate-Limits prüfen, bevor an CiviCRM weitergeleitet wird. Der Browser-Direkt-Weg steht hier, weil bestehende Anleitungen ihn voraussetzen und weil der Lockdown aus Abschnitt 3 ihn für risikoarme Setups akzeptabel macht - aber akzeptabel ist nicht ideal.

Voraussetzungen

CiviCRM einrichten

1. Mailing-Gruppe erstellen

Gehen Sie zu Kontakte -> Gruppen verwalten -> Neue Gruppe. Erstellen Sie eine Gruppe (z. B. “Newsletter”) und setzen Sie den Gruppentyp auf “Mailing List”.

Entscheidend: Setzen Sie die Sichtbarkeit auf “Public Pages”. Das übersieht man leicht. Die API-Action MailingEventSubscribe, die den Double-Opt-In-Ablauf auslöst, prüft, ob die Zielgruppe öffentlich ist. Wenn die Sichtbarkeit auf “User and User Admin Only” steht (der Standard), lehnt die API die Anmeldung mit einem “Group is not Public”-Fehler ab. Kein hilfreicher Hinweis, nur eine klare Absage. Also: auf “Public Pages” setzen.

2. FormProcessor konfigurieren

FormProcessor ist eine CiviCRM-Extension, mit der Sie wiederverwendbare API-Actions definieren können - im Grunde benannte Prozeduren, die mehrere Schritte bündeln und als eine einzige API-Aktion verfügbar machen. Wir erstellen eine namens newsletter_signup.

  1. Gehen Sie zu Administer -> Automation -> FormProcessors
  2. Klicken Sie auf “Add Form Processor”
  3. Nennen Sie ihn newsletter_signup (der technische Name ist wichtig - er wird zum API-Action-Namen)
  4. Fügen Sie einen Eingabeparameter hinzu: Name email, Typ “String” (oder “Email”, falls verfügbar), als erforderlich markieren
  5. Fügen Sie eine Action zum Finden oder Erstellen eines Kontakts hinzu:
    • Action-Typ: “Get or Create Contact by Email”
    • Den E-Mail-Eingabeparameter auf diese Action mappen
  6. Fügen Sie eine zweite Action für die Anmeldung hinzu:
    • Action-Typ: “MailingEventSubscribe” (oder eine generische “API Call”-Action mit Entity MailingEventSubscribe und Action create)
    • Den Parameter email auf den E-Mail-Eingabeparameter setzen
    • group_id auf die ID Ihrer Newsletter-Gruppe setzen (die Gruppen-ID finden Sie in der URL beim Bearbeiten der Gruppe oder im API Explorer)
    • contact_id aus dem Ergebnis der vorherigen “Get or Create Contact”-Action mappen

Nach dem Speichern können Sie das im CiviCRM API Explorer testen (Support -> Developer -> API Explorer v4), indem Sie FormProcessor.newsletter_signup mit einer Test-E-Mail-Adresse aufrufen. Wenn alles funktioniert, verschickt CiviCRM eine Bestätigungsmail mit Double-Opt-In-Link an diese Adresse.

3. API-Benutzer minimal absichern

Das Snippet im nächsten Abschnitt schickt Ihren API-Key in den Browser jeder Besucher:in. Jede:r kann ihn aus dem Quelltext kopieren und mit curl wiederverwenden. Behandeln Sie diesen Benutzer also wie bereits kompromittierte Zugangsdaten und vergeben Sie nur das absolute Minimum an Berechtigungen.

Ziel: Selbst wenn der Schlüssel abgegriffen wird, kann er ausschließlich Ihren newsletter_signup-FormProcessor auslösen - nicht Contact.get, nicht Contact.create direkt, keinen anderen API-Aufruf.

Das ist möglich, weil FormProcessor zwei nützliche Eigenschaften hat:

  1. Jeder FormProcessor hat ein konfigurierbares Permission-Feld - die einzige Berechtigung, die zum Auslösen nötig ist (FormProcessor überschreibt die Default-Anforderung administer CiviCRM über den Hook alterAPIPermissions).
  2. Die internen Sub-Actions (“Get or Create Contact by Email”, “MailingEventSubscribe”) laufen mit System-Rechten. Der aufrufende Benutzer braucht kein add contacts oder andere “echte” CiviCRM-Berechtigungen, damit diese Schritte durchlaufen.

Damit funktioniert die Absicherung so:

  1. Eigenen Kontakt und Login-Benutzer anlegen, getrennt vom menschlichen Admin. In Standalone den Login ohne nutzbares Passwort einrichten, sodass das Konto nur per API-Key erreichbar ist.

  2. Permission-Zeichenkette am FormProcessor setzen - und auf Standalone registrieren. Bearbeiten Sie Ihren newsletter_signup-FormProcessor und tragen Sie ins Feld Permission etwas Unverwechselbares ein, z. B. newsletter signup api.

    Ob dieser String einfach funktioniert, hängt von Ihrer CiviCRM-Variante ab - und das ist der größte Fallstrick im ganzen Setup:

    • CiviCRM Standalone: Der String muss eine registrierte Permission sein, sonst lässt er sich gar nicht zuweisen. Eine Standalone-Rolle speichert ihre Permissions in einem Feld, dessen erlaubte Werte die Pseudoconstant-Liste der bekannten Permissions sind - Core plus alles, was über hook_civicrm_permission angemeldet wird. Beim Speichern einer Rolle verwirft APIv4 jede Permission-Zeichenkette, die nicht auf dieser Liste steht - still, ohne Fehler. Ein unregistrierter Custom-String landet also nie auf der Rolle, der API-Benutzer hält ihn nie, und jeder Aufruf scheitert mit API3 Request Authorization failed - obwohl die Rolle in der UI korrekt aussieht. (Ältere CiviCRM-Versionen waren hier laxer, weshalb dieser Artikel ursprünglich „jeder String funktioniert" behauptete.)
    • Drupal / WordPress: Der Check selbst akzeptiert jeden String - CRM_Core_Permission_Drupal8::check() reicht einfach an das hasPermission() des CMS durch, ein simpler String-Mitgliedschaftstest, und das CMS entfernt unbekannte Permissions nicht aus einer Rolle. Aber ein nicht angemeldeter String taucht in der Permissions-UI des CMS nicht auf, Sie können ihn also nur per Code/drush/Config vergeben, nicht per Häkchen. Registrieren Sie ihn, erscheint er wie jede andere Permission.

    In beiden Fällen ist die saubere Lösung, die Permission über hook_civicrm_permission anzumelden. Zwei Optionen:

    • formprocessorperms installieren. Eine winzige Extension, die die konfigurierte Permission jedes FormProcessors ausliest und alle automatisch registriert - einmal installiert, wird jede Permission, die Sie in irgendeinen FormProcessor eintragen, zuweisbar. Getestet auf Standalone, Drupal 10/11, WordPress und Joomla.
    • Oder selbst schreiben - eine Extension mit einem einzigen Hook:
      function myext_civicrm_permission(&$permissions) {
        $permissions['newsletter signup api'] = [
          'label' => 'Newsletter signup API',
          'description' => 'Invoke the newsletter_signup form processor.',
        ];
      }
      
      civix generate:module myext scaffoldet das in Sekunden.

    Siehe Alternativen zum Registrieren einer Permission unten, falls Sie das ganz umgehen möchten.

  3. Dem API-Benutzer nur diese Berechtigungen geben (über Roles in Standalone, über das Benutzerprofil in anderen Deployments):

    • authenticate with api key - nötig, damit die API-Key-Authentifizierung (die authx-Extension) den Benutzer überhaupt einloggt. Ohne sie läuft der Request anonym und scheitert an der Autorisierung, egal was die Rolle sonst hält.
    • access AJAX API - nötig, damit /civicrm/ajax/rest überhaupt antwortet.
    • newsletter signup api - Ihre Custom-Permission aus Schritt 2 (registriert gemäß diesem Schritt).
  4. Auf keinen Fall vergeben:

    • administer CiviCRM, access CiviCRM
    • add contacts, view all contacts, edit all contacts
    • access CiviMail subscribe/unsubscribe pages
    • alles rund um Contributions / Memberships / Events
  5. API-Key generieren. Im Kontakt des Benutzers den Reiter API Key öffnen und den Schlüssel erzeugen.

  6. Site-Key. Auf dem Server civicrm.settings.php öffnen und den Wert von CIVICRM_SITE_KEY kopieren:

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

Absicherung verifizieren. Mit demselben API-Key einen anderen Endpunkt aufrufen:

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

Sie sollten einen Permission-Denied-Fehler zurückbekommen. Falls Kontaktdaten erscheinen, hat der Benutzer mehr Rechte als nötig - zurück zu Schritt 4.

Was das trotzdem NICHT verhindert. Auch mit diesem Minimal-Setup kann jede:r mit dem Schlüssel beliebige fremde E-Mail-Adressen an den FormProcessor schicken. Jede Anmeldung löst eine Bestätigungsmail von Ihrer CiviCRM-Instanz aus. Ein:e Angreifer:in kann das nutzen, um Fremde über Ihre Domain zu spammen - schlecht für die Betroffenen und schlecht für die Sende-Reputation Ihres Servers. Permission-Lockdown ist die Untergrenze, nicht das Komplettpaket. Siehe Hardening unten für die Schichten oben drauf.

Alternativen zum Registrieren einer Permission

Falls Sie sich mit dem Registrieren von Permissions gar nicht befassen möchten, drei Wege drumherum:

Für die meisten ist der Weg mit dem geringsten Aufwand aber schlicht, formprocessorperms (Schritt 2) einzubinden und weiterzumachen; die Alternativen oben zählen vor allem, wenn Sie keine Extensions installieren können oder den Key ganz vom Client fernhalten wollen.

Das Formular bauen

Hier ist ein vollständiges, eigenständiges HTML/CSS/JS-Snippet, das Sie in jede Website einfügen können. Es spricht die CiviCRM REST API direkt aus dem Browser mit einem einfachen XMLHttpRequest an - keine Abhängigkeiten, kein Build-Schritt, kein jQuery.

Ersetzen Sie die drei Platzhalter-Variablen am Anfang: CIVICRM_URL, API_KEY und 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>Newsletter abonnieren</h3>
  <p>Erhalten Sie Updates direkt in Ihr Postfach. Wir respektieren Ihre Privatsphäre.</p>

  <form id="nl-form" onsubmit="return nlSubmit(event)">
    <label for="nl-email">E-Mail-Adresse</label>
    <input type="email" id="nl-email"
           placeholder="name@beispiel.de" required>
    <button type="submit" id="nl-btn">Abonnieren</button>
  </form>
  <div id="nl-msg" class="nl-msg"></div>

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

      // ===== DIESE DREI WERTE ERSETZEN =====
      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 = "Wird gesendet...";
      msg.className = "nl-msg";
      msg.style.display = "none";

      // CiviCRM Standalone weist GET für schreibende Actions ab ("Destructive HTTP GET"),
      // deshalb POST. Die Keys landen im Body - so tauchen sie nicht im Access-Log
      // des Webservers als URL-Parameter auf.
      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 = "Abonnieren";

        if (xhr.status === 200) {
          try {
            var resp = JSON.parse(xhr.responseText);
            if (resp.is_error && resp.is_error !== 0) {
              msg.textContent = "Fehler: " + (resp.error_message || "Unbekannter Fehler");
              msg.className = "nl-msg error";
            } else {
              msg.textContent = "Vielen Dank! Bitte prüfen Sie Ihr Postfach "
                + "und klicken Sie auf den Bestätigungslink, um "
                + "Ihre Anmeldung abzuschließen (Double Opt-In).";
              msg.className = "nl-msg success";
              document.getElementById("nl-email").value = "";
            }
          } catch(ex) {
            msg.textContent = "Unerwartete Antwort vom Server.";
            msg.className = "nl-msg error";
          }
        } else {
          msg.textContent = "Anfrage fehlgeschlagen (HTTP " + xhr.status
            + "). Bitte versuchen Sie es später erneut.";
          msg.className = "nl-msg error";
        }
      };
      xhr.send(body);
      return false;
    }
  </script>
</div>

Dieses Snippet ist vollständig eigenständig. Die Styles sind auf den Wrapper #newsletter-signup beschränkt, damit sie nicht mit den vorhandenen Styles Ihrer Website kollidieren. Es rendert eine schlichte Karte mit Schatten, abgerundeten Ecken und rotem Absende-Button.

CORS konfigurieren

Wenn Ihre Website (z. B. www.example.com) und CiviCRM (z. B. crm.example.com) auf unterschiedlichen Domains liegen, blockiert der Browser den AJAX-Request, sofern der CiviCRM-Server nicht die passenden CORS-Header sendet.

Für Apache fügen Sie Folgendes in die .htaccess im Webroot Ihrer CiviCRM-Standalone-Installation ein:

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

Wenn Sie mehrere Origins erlauben müssen, brauchen Sie eine dynamischere Konfiguration mit SetEnvIf oder einer Rewrite-Regel. Für eine schnelle Entwicklungsumgebung können Sie * als Origin verwenden; in Produktion sollten Sie immer auf Ihre tatsächliche Domain einschränken.

Für nginx verwenden Sie add_header-Direktiven im Server- oder 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;

    # Preflight-Requests beantworten
    if ($request_method = OPTIONS) {
        return 204;
    }
}

Starten Sie nach dem Hinzufügen der CORS-Header den Webserver neu und testen Sie erneut. Öffnen Sie die Entwicklerkonsole des Browsers und prüfen Sie, dass der Request an crm.example.com keinen CORS-Fehler mehr zeigt.

WordPress-Integration

Wenn Ihre öffentliche Website auf WordPress läuft, haben Sie je nach Theme zwei Optionen: klassisches Theme oder Block-Theme.

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

Dieser Ansatz funktioniert gut mit klassischen Themes und gibt Ihnen ein Formular, das Sie im WordPress-Admin verwalten können.

  1. Installieren und aktivieren Sie das Plugin Contact Form 7
  2. Installieren und aktivieren Sie das Plugin “Contact Form 7 CiviCRM Integration”
  3. Erstellen Sie ein neues Contact-Form-7-Formular mit einem E-Mail-Feld
  4. Konfigurieren Sie im CiviCRM-Integration-Tab des Formulars:
    • CiviCRM Host: https://crm.example.com
    • API Key: Ihr API-Key
    • Site Key: Ihr Site-Key
    • REST Path: /civicrm/ajax/rest
  5. Setzen Sie die Action auf FormProcessor.newsletter_signup und mappen Sie das E-Mail-Feld

Wichtig: Achten Sie darauf, dass der REST-Pfad auf /civicrm/ajax/rest gesetzt ist und nicht auf den alten Pfad /sites/all/modules/civicrm/extern/rest.php oder /core/extern/rest.php. CiviCRM Standalone hat keine civicrm.config.php an der Stelle, an der extern/rest.php sie erwartet; dieser Endpunkt liefert deshalb einen HTTP-500-Fehler. Die Route /civicrm/ajax/rest läuft durch das normale CiviCRM-Routing und funktioniert korrekt mit Standalone.

Option B: HTML-Block im Block-Editor (Block-Themes)

Wenn Sie ein modernes Block-Theme verwenden (oder einfach keine zusätzlichen Plugins installieren möchten), können Sie das HTML/JS-Snippet direkt in einen Custom HTML-Block im WordPress-Block-Editor einfügen.

Das funktioniert - mit einer wichtigen Einschränkung.

WordPress entfernt <script>, <form> und <input>-Tags. WordPress hat einen Sicherheitsfilter namens kses, der HTML-Inhalte bereinigt. Wenn Sie eine Seite über den Block-Editor speichern (der intern die REST API nutzt), läuft kses und entfernt still alle Tags, die als unsicher gelten - darunter <script>, <form> und oft auch <input>. Sie fügen das Snippet ein, klicken auf Speichern, laden die Seite neu und stellen fest: Formular und JavaScript sind einfach verschwunden.

Die Lösung ist, kses-Filtering für REST-API-Requests zu deaktivieren. Legen Sie dafür ein kleines Must-Use-Plugin (mu-plugin) unter wp-content/mu-plugins/disable-kses-rest.php an:

<?php
/**
 * Plugin Name: Disable kses for REST API
 * Description: Verhindert, dass WordPress script/form/input-Tags
 *              beim Speichern via REST API entfernt (Block-Editor).
 */

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

Das prüft, ob der aktuelle Benutzer die Berechtigung unfiltered_html hat (Administratoren haben sie standardmäßig), bevor der Filter deaktiviert wird. So können nur vertrauenswürdige Benutzer ungefiltertes HTML speichern, während die Einschränkung für niedrigere Rollen bestehen bleibt.

Gehen Sie nach dem Anlegen der Datei zurück zur Seite, fügen Sie das HTML/JS-Snippet in einen Custom-HTML-Block ein und speichern Sie. Diesmal bleiben Formular- und Script-Tags erhalten.

Integration in andere CMS / statische Websites

Das HTML/JS-Snippet von oben ist einfacher, dependency-freier Code. Es funktioniert überall, wo Sie HTML und JavaScript platzieren können. Ein paar konkrete Hinweise:

Sie brauchen nur eine Seite, die über HTTPS ausgeliefert wird (für sichere API-Aufrufe), und korrekt konfigurierte CORS-Header auf dem CiviCRM-Server, falls die Domains unterschiedlich sind.

Hardening

Der eingeschränkte API-Benutzer aus Abschnitt 3 ist das Minimum, das Sie ausliefern sollten. Für sich allein lässt er aber weiterhin zu, dass jemand mit dem Key Subscribe-Versuche an beliebige Adressen absetzt, was heißt: Ihr CiviCRM verschickt Ihre Bestätigungsmails an Personen, die ein:e Angreifer:in ausgesucht hat. Die drei folgenden Maßnahmen schließen die verbleibende Lücke. Die dritte - der Server-seitige Proxy - ist die, die ich für jede produktive Site tatsächlich empfehlen würde; die ersten beiden sind auch dann nützlich, wenn Sie schon einen Proxy haben.

Server-seitiger Proxy (für alles Ernste empfohlen)

Das richtige Muster für Production ist, den API-Key gar nicht erst in den Browser zu legen. Stattdessen:

  1. Das Formular auf der öffentlichen Website postet an einen eigenen kleinen Endpunkt (eine WordPress-REST-Route, einen Cloudflare Worker, eine Netlify Function, einen Mini-Server).
  2. Dieser Endpunkt hält den API-Key serverseitig in einer Umgebungsvariable.
  3. Der Endpunkt prüft das CAPTCHA-Token, optional die Origin, setzt sein eigenes Rate-Limit, und ruft erst dann CiviCRM auf.

Damit ist das Leak-Problem vom Tisch. Der Preis: ein Stück Backend-Code zu pflegen - aber das ist wirklich klein (~50 Zeilen PHP/JS). Für WordPress-Seiten erledigt das bestehende Plugin CF7-CiviCRM-Integration genau das: Sie binden ein Contact-Form-7-Formular ein, das Plugin hält den API-Key serverseitig in den WP-Options, und CiviCRM wird vom WP-Backend aus aufgerufen. Das ist ein Abend Setup-Aufwand und liefert Ihnen einen Server-seitigen Proxy ohne eigenen Code.

CAPTCHA einbauen

Setzen Sie Cloudflare Turnstile (kostenlos, ohne Bilderrätsel) oder hCaptcha vor das Formular. Das Widget produziert ein Token, das Sie serverseitig prüfen, bevor Sie an CiviCRM weiterleiten. Das setzt den Server-seitigen Proxy aus dem Abschnitt davor voraus - es gibt keinen sauberen Weg, ein CAPTCHA-Token aus einem reinen Browser-Setup zu verifizieren.

/civicrm/ajax/rest rate-limitieren

Wenn CiviCRM hinter Cloudflare läuft, eine Rate-Limiting-Regel einrichten (z. B. 5 Requests pro Minute pro IP für /civicrm/ajax/rest). Bei nginx geht das Gleiche mit limit_req_zone und limit_req. Wählen Sie eine niedrige Zahl - reguläre Nutzer:innen senden das Formular nur einmal ab. Das hilft unabhängig davon, ob Sie den Browser-Direkt-Weg oder einen Proxy nutzen.

Nach der Anmeldung: unbestätigte Abos und Listen-Hygiene

Das Formular zum Laufen zu bringen ist nur die halbe Miete. Die andere Hälfte ist, was danach mit den Anmeldungen passiert - besonders mit denen, die nie bestätigt werden. Dieser Teil ist kaum dokumentiert, und genau hier verrottet ein funktionierendes Setup still vor sich hin.

Was CiviCRM mit unbestätigten Anmeldungen macht (und was nicht)

Warum das zählt: Spam-Listen-Bombing

Ein öffentliches Anmeldeformular ohne echten Bot-Schutz ist ein Magnet für Listen-Bombing: Bots tragen tausende fremde Adressen ein. Jede Einsendung lässt Ihr CiviCRM Ihre Bestätigungsmail an eine vom Bot gewählte Adresse schicken - was Ihre Sende-Reputation ruiniert - und hinterlässt einen Pending-Müllkontakt.

Erkennen können Sie es sofort an der Bestätigungsrate. Echte Menschen bestätigen rund zur Hälfte. In einer echten Flut, die ich aufräumen musste, produzierte ein Formular etwa 2.400 Subscribes bei ~1 % Bestätigungsrate - und von ~1.900 dieser „Abonnenten" hatte genau einer je eine Spende getätigt. Das ist keine Anmelde-Welle, das ist ein Bot.

Prävention zuerst

Aufräumen behandelt nur das Symptom. Halten Sie den Müll an der Tür ab:

Eine Aufräum-Routine für alte Pending

Da nichts sie für Sie prunt, bauen Sie eine wiederholbare Routine mit SearchKit - ohne Code:

  1. Suchen Sie Group Contact mit Gruppe = Ihr Newsletter, Status = Pending und Subscribe-/Added-Datum älter als z. B. 30 Tage.
  2. Verschärfen Sie sie, damit Sie nur echten Ballast erwischen: „hat keine Contributions" und „nicht Added in einer anderen Gruppe" ergänzen, damit ein echter Spät-Bestätiger oder ein anderweitig aktiver Kontakt nie mitgelöscht wird.
  3. Ergebnis prüfen, dann eine Bulk-Aktion Aus Gruppe entfernen (konservativ - Kontakt bleibt) oder Kontakte löschen (für reinen Spam ohne sonstigen Wert).

Führen Sie das regelmäßig von Hand aus oder automatisieren Sie es mit einem CiviRules-Tagestrigger. Weil es eine gespeicherte Suche ist, sind die Kriterien nachvollziehbar - viel sicherer als ein einmaliges DELETE.

Anmerkung zur Einwilligung (DSGVO)

Ein unbestätigter Opt-In ist streng genommen keine Einwilligung. Solche Datensätze unbegrenzt zu halten ist DSGVO-rechtlich schwer zu begründen - ein regelmäßiges Pruning nie bestätigter Pending-Kontakte ist also nicht nur Ordnung, sondern guter Datenschutz.

Über eine Liste hinaus: mehrere Newsletter und markenspezifische Absender

Alles oben trägt Menschen in eine Gruppe ein und schickt die Bestätigung von einer Adresse - dem Domain-Default Ihres CiviCRM. Für einen einzelnen Newsletter ist das genau richtig. Aber sobald Sie zwei Marken aus einem CiviCRM betreiben - etwa zwei Vereine mit je eigener Domain - stößt der Core-MailingEventSubscribe-Weg an eine Wand: Er setzt das From der Bestätigung fest auf den Domain-Default (CRM_Core_BAO_Domain::getNameAndEmail()), und der Body ist eine einzige globale „Subscribe"-Mailing-Komponente. Gleicher Absender, gleiche Vorlage, für jede Liste. Den Namen der Liste können Sie per Token unterscheiden, die Absender-Domain oder das Branding nicht.

Wenn Sie markenspezifische Absender, gebrandete Bestätigungsmails, ein Präferenz-Center oder DSGVO-Einwilligungs-Datensätze brauchen, ist das dafür gebaute Werkzeug SYSTOPIAs Advanced Newsletter Management (de.systopia.newsletter, plus die harte Abhängigkeit de.systopia.xcm). Es ersetzt den FormProcessor-+-MailingEventSubscribe-Weg durch eigene Profile: Jedes Profil bindet eine Gruppenmenge, eine Absender-Adresse und eigene Opt-In-/Info-/Abmelde-Vorlagen. Ein Profil pro Marke, fertig - nativ, ohne alterMailParams-Hook.

Ein Selbstläufer ist es aber nicht. Es bringt keine öffentlichen Seiten mit - nur Admin-Config. Der komplette öffentliche Fluss ist API (NewsletterSubscription.submit / .request / .confirm), Sie bauen das Anmeldeformular und die Bestätigungs-Landingpage also selbst, genau wie beim Core-Weg oben. Was Sie gewinnen, sind Absender und Vorlagen pro Profil; was Sie zahlen, ist etwas mehr Verdrahtung. Hier die scharfen Kanten, in die ich gelaufen bin - keine davon steht in der Doku.

sender_email am Profil ist eine ID, keine Adresse. Auf CiviCRM 6.x (mit der SiteEmailAddress-Entity) löst das Profil seinen Absender über deren id auf, nicht über das "Name" <email>-Label. Speichern Sie das Label, schlägt der Lookup still fehl und fällt auf den Domain-Default zurück - die Bestätigung Ihrer zweiten Marke geht dann vom Absender der ersten raus, und nichts wirft einen Fehler. Speichern Sie die numerische id. Und achten Sie auf dieselbe Adresse doppelt (z. B. info@… unter zwei Anzeigenamen): über E-Mail und Anzeigename auflösen, sonst erwischen Sie die falsche.

Das Default-XCM-Profil erzeugt Dubletten. submit matcht oder erzeugt den Kontakt über XCM, und das mitgelieferte default-XCM-Profil dedupliziert nicht allein über die E-Mail. Zwei Anmeldungen mit derselben Adresse - oder ein bestehendes Mitglied, das sich anmeldet - erzeugen jedes Mal einen zweiten Kontakt. Richten Sie ein dediziertes XCM-Profil ein, das nur über die E-Mail matcht (die EmailOnlyMatcher-Regel), ohne Felder zu überschreiben, und setzen Sie xcm_profile jedes Newsletter-Profils darauf. Eine öffentliche Anmeldung darf bestehende Kontaktdaten niemals verändern.

confirm ist ein Zwei-Argument-Tanz und schickt eine zweite Mail. Es braucht contact_id und contact_checksum (der Checksum ist self-contained - {cid}_{hash} -, die Kontakt-ID ist also der Teil vor dem ersten _), plus entweder eine mailing_lists-Map {group_id: 'Added'} oder autoconfirm=1. Und Achtung: Nach der Bestätigung sendet es die info-Vorlage des Profils. Ließen Sie die leer (wie das Default-Profil), bekommen Ihre Abonnenten eine leere Mail, die direkt im Spam landet. Branden Sie die Info-Vorlage, oder schalten Sie sie ab.

Bestätigen per POST, nie per GET. Der Bestätigungslink landet auf Ihrer Seite; bestätigt die beim Laden, werden Mail-Security-Scanner und Link-Preview-Bots jeden Abonnenten für ihn auto-bestätigen. Zeigen Sie einen Button; bestätigen Sie nur beim expliziten POST. Und verdrahten Sie den Profilnamen serverseitig fest pro Site - übernehmen Sie ihn nicht aus dem Querystring, sonst kann jemand seinen eigenen Kontakt gegen die falsche Marke bestätigen.

Ein Pluspunkt gegen die frühere Permission-Saga: Diese Extension registriert ihre eigene Permission access Advanced Newsletter Management API über hook_civicrm_permission - auf Standalone ist sie also vergebbar und wird nicht still aus der Rolle gefiltert. Geben Sie diese eine Permission Ihrem abgeschotteten API-User für submit und confirm.

Zum Reifegrad, ehrlich: Es ist APIv3 und Smarty-2 (über den smarty-v2-Kompatibilitäts-Mixin) und mit develStage=dev markiert. Es wird aktiv gepflegt und breit eingesetzt, aber pinnen Sie Extension und XCM auf feste Tags und testen Sie nach jedem Core-Upgrade neu. Für einen einzelnen Newsletter ist der FormProcessor-Weg oben einfacher, und ich bliebe dabei; greifen Sie zu Profilen, wenn Sie wirklich mehrere Absender auseinanderhalten müssen.

Troubleshooting / Häufige Fallstricke

Hier sind die Probleme, über die ich gestolpert bin, damit Sie direkt zur Lösung springen können.

“API3 Request Authorization failed”, obwohl die Rolle die Permission hat

Sie haben die Permission vergeben, die Rolle zeigt sie an, und Aufrufe scheitern trotzdem. Auf Standalone heißt das fast immer, dass der Permission-String nicht registriert ist: Das permissions-Feld der Rolle akzeptiert nur bekannte Permissions, ein unregistrierter Custom-String wird beim Speichern der Rolle still verworfen und erreicht die Datenbank nie. Prüfen Sie den Rohwert mit SELECT permissions FROM civicrm_role WHERE id = <id>; fehlt Ihr String, registrieren Sie ihn (siehe Schritt 2 - formprocessorperms oder ein eigenes hook_civicrm_permission) und speichern Sie die Rolle erneut. Stellen Sie außerdem sicher, dass die Rolle authenticate with api key hat, sonst läuft der Request anonym und scheitert genauso.

Ein praktisches Debugging-Detail: Der APIv3-REST-Endpunkt nennt die exakt fehlende Permission (insufficient permission: require newsletter signup api), während der APIv4-Endpunkt sie bewusst zu einem nackten HTTP 403 mit Fehler-ID anonymisiert. Wenn Sie an einem 403 von /civicrm/ajax/api4/… hängen, wiederholen Sie denselben Aufruf gegen /civicrm/ajax/rest (APIv3), um zu sehen, welche Permission tatsächlich verlangt wird.

extern/rest.php gibt HTTP 500 zurück

Wenn Sie https://crm.example.com/core/extern/rest.php (oder irgendeinen extern/rest.php-Pfad) aufrufen und einen 500er bekommen, liegt das daran, dass CiviCRM Standalone die Datei civicrm.config.php nicht an der Stelle ablegt, an der extern/rest.php sie laden möchte. Die Lösung ist einfach: Verwenden Sie stattdessen /civicrm/ajax/rest als API-Endpunkt. Das ist die moderne, unterstützte Route für die REST API von CiviCRM Standalone.

“Group is not Public”-Fehler

Wenn Sie beim Aufruf von MailingEventSubscribe.create diesen Fehler sehen, ist die Sichtbarkeit Ihrer Ziel-Mailing-Gruppe auf “User and User Admin Only” gesetzt. Gehen Sie zu Kontakte -> Gruppen verwalten, bearbeiten Sie die Gruppe und ändern Sie die Sichtbarkeit auf “Public Pages”. Die MailingEventSubscribe-API erzwingt diese Prüfung, weil der Double-Opt-In-Ablauf für öffentliche Anmeldungen gedacht ist.

“SECURITY: Destructive HTTP GET”-Fehler

Wenn der Request {"error_message":"SECURITY: All requests that modify the database must be http POST, not GET.","is_error":1} zurückgibt, schicken Sie einen GET-Request an eine schreibende Action. CiviCRM Standalone erzwingt POST für alle API-Aufrufe, die Daten ändern - inklusive MailingEventSubscribe.create und des FormProcessors, der ihn umschließt. Die Lösung: XMLHttpRequest (oder fetch) auf POST umstellen und die Parameter in den Request-Body packen, nicht in die URL. Das Snippet im Abschnitt Das Formular bauen macht das bereits so; ältere Versionen dieser Anleitung verwendeten GET und liefen genau in diesen Fehler.

CORS-Fehler in der Browser-Konsole

Wenn Sie Fehler wie Access to XMLHttpRequest at 'https://crm.example.com/...' from origin 'https://www.example.com' has been blocked by CORS policy sehen, sendet Ihr CiviCRM-Server nicht die richtigen CORS-Header. Siehe Abschnitt CORS-Konfiguration oben. Denken Sie daran, Apache oder nginx nach dem Hinzufügen der Header neu zu starten. Beachten Sie außerdem, dass Browser CORS-Preflight-Antworten recht aggressiv cachen können - machen Sie einen Hard Refresh oder testen Sie in einem privaten Fenster.

WordPress entfernt Formular-/Script-Tags

Sie fügen das HTML-Snippet in einen Custom-HTML-Block ein, speichern, und die <script>- und <form>-Tags verschwinden. Das ist die kses-Sanitization von WordPress. Der Block-Editor speichert Inhalte über die REST API, und kses läuft bei REST-API-Requests. Installieren Sie das mu-plugin aus dem WordPress-Abschnitt oben, um kses für Admin-Benutzer bei REST-API-Requests zu deaktivieren.

API-Berechtigungsfehler bei System.get

Einige CiviCRM-API-Integrationen (z. B. das CF7-CiviCRM-Plugin) rufen System.get auf, um die Verbindung zu validieren. Diese Action benötigt administer CiviCRM. Der eingeschränkte API-Benutzer aus Abschnitt 3 hat diese Berechtigung nicht - und soll sie auch nicht haben. Der Verbindungstest schlägt also fehl; die eigentliche Newsletter-Anmeldung funktioniert trotzdem. Behandeln Sie das fehlgeschlagene Test-Result als erwartet und gehen Sie weiter. Wenn Sie unbedingt einen grünen Verbindungstest brauchen, machen Sie ihn einmalig mit einem temporären Admin-Key und tauschen den Key danach gegen den eingeschränkten für den Produktionsbetrieb aus.


Das war’s. Wenn alles verbunden ist, läuft der Ablauf so: Besucher:in trägt E-Mail ein -> Ihr Formular ruft die REST API von CiviCRM auf -> FormProcessor erstellt/findet den Kontakt und meldet ihn an -> CiviCRM verschickt eine Double-Opt-In-Bestätigungsmail -> Besucher:in klickt den Link -> die Anmeldung ist abgeschlossen. Kein zusätzlicher Newsletter-Dienst, volle Kontrolle über Ihre Daten, und es funktioniert mit jedem Frontend.