Engineering

Match-Quality serverseitig anheben

Event Match Quality ist eine Zählung, wie viele identifizierende Signale pro Event bei der Plattform ankommen. Die meisten Server-Side-Setups senden drei oder vier und hören da auf. Beaconry erledigt die Match-Key-Arbeit stattdessen auf dem Server: ein first-party verschlüsselter Tresor, der Identifier über Events hinweg merkt, GeoIP- und _fbp-Enrichment, das die Lücken füllt, die sonst ein Browser-Pixel füllen würde, und strikte Kanonisierung, damit dieselbe Person jedes Mal zum selben Wert hasht. Kein Browser-Pixel nötig.

Lesezeit: ~9 MinVeröffentlicht: 2026-06-08

Was Match-Quality eigentlich zählt

Metas Event Match Quality, TikToks Matched-Event-Rate, Pinterests Match-Rate-Score: das ist alles dieselbe Idee unter verschiedenen Namen. Die Plattform erhält ein Event, versucht es an eine echte Person in ihrem Graph zu binden, und benotet dich danach, wie viele brauchbare Signale du übergeben hast. Gehashte E-Mail matcht stark. Gehashte Telefonnummer matcht stark. Vorname plus Nachname plus Stadt plus PLZ grenzen weiter ein. Eine Click-ID (fbc, ttclid) oder ein first-party Browser-Cookie (_fbp) verankert das Event an einem bekannten Browser. IP und User-Agent sind für sich genommen schwach, heben aber in Kombination die Zuversicht.

Das naive Server-Side-Setup sendet, was WordPress zufällig gerade herumliegen hat, wenn das Event feuert. Bei einem Checkout ist das eine Menge: die Kundin hat E-Mail, Telefon, Name und Adresse ins Bestellformular getippt. Bei einem schlichten Pageview aus einem Ad-Klick ist es fast nichts, weil die Besucherin noch nichts übergeben hat. So kommen ausgerechnet die Events, die für die Optimierung am wichtigsten sind, die Top-of-Funnel-Events, die die Plattform nutzt, um zu lernen, wem sie deine Anzeige zeigt, mit den dünnsten Match-Daten an. Genau diese Lücke schließt diese Arbeit.

Die Browser-Pixel-Antwort darauf ist bekannt: Metas oder TikToks eigenes JavaScript setzt seine eigenen first-party Cookies und liest sie zurück. Das funktioniert, holt aber das Drittanbieter-Script zurück, das Adblocker entfernen, kostet Bytes und bricht die "kein Third-Party-Tracking"-Haltung. Der Hybrid-Modus deckt die Fälle ab, in denen sich dieser Tausch lohnt (wir haben einen ganzen Beitrag darüber geschrieben, wann man ihn einschaltet). Alles weiter unten ist, was Beaconry tut, bevor du zum Hybrid-Modus greifst, vollständig serverseitig, für die Besucher, die ein geblocktes Pixel leer gelassen hätte.

Der PII-Tresor: Identifier über Events hinweg merken

Das Kernproblem anonymer Top-of-Funnel-Events ist das Timing. Eine Besucherin klickt eine Meta-Anzeige, landet auf einem Blog-Post (Pageview, keine PII), liest ihn, kommt zwei Tage später aus einer Google-Suche zurück, durchstöbert drei Produkte (drei view_item-Events, noch immer keine PII) und füllt erst beim vierten Besuch ein Kontaktformular aus. Der Form-Submit hat eine starke E-Mail. Die elf Events davor hatten nichts. Ohne Persistenz bleiben diese elf Events für immer Low-Match, und die Plattform lernt nie, dass die Person, die schließlich konvertiert ist, dieselbe ist, die die Anzeige geklickt hat.

Beaconrys PII-Tresor behebt die Asymmetrie. Sobald irgendein Event einen echten Identifier trägt, eine E-Mail aus einem Formular, einen Namen und eine Adresse aus einer WooCommerce-Bestellung, speichert der Tresor ihn. Jedes folgende Event liest ihn zurück und wird mit denselben Match-Keys angereichert. Die Form-Submit-E-Mail wandert vorwärts zu jedem späteren Pageview und jeder Produktansicht aus diesem Browser.

Der Speicher ist ein einzelnes first-party Cookie namens nl_v. Es ist kein Klartext-Cookie. Die Payload ist das Identifier-Set, JSON-kodiert, dann AES-256-GCM-verschlüsselt mit einem Schlüssel, der aus dem site-eigenen wp_salt('auth') abgeleitet ist:

// key derivation: fold the 64-byte auth salt to 32 bytes for AES-256
private static function derive_key(): string {
    return hash( 'sha256', (string) wp_salt( 'auth' ), true );
}

// encrypt: random 12-byte IV, 16-byte GCM tag, base64( iv ++ tag ++ ciphertext )
$iv         = random_bytes( 12 );
$ciphertext = openssl_encrypt( $plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
return base64_encode( $iv . $tag . $ciphertext );

Der Browser sieht immer nur Base64 von authentifiziertem Ciphertext. Er kann die E-Mail nicht zurücklesen und nicht manipulieren (GCM ist authentifiziert, ein gekipptes Byte scheitert beim Entschlüsseln am Tag-Check, und der Tresor behandelt es als nicht vorhanden). Nach DSGVO Artikel 4(5) macht das den Cookie zu einem pseudonymisierten Speicher: nutzlos ohne den serverseitigen Schlüssel, der den Server nie verlässt. Der Cookie wird HttpOnly gesetzt (kein JavaScript-Zugriff), SameSite=Lax (kein Cross-Site-Leak) und Secure bei HTTPS.

Zehn Felder kommen für den Tresor infrage, genau das Set, das die CAPI-Hashing-Pipeline erkennt:

private const VAULT_FIELDS = [
    'em', 'ph', 'fn', 'ln', 'ct', 'st', 'zp', 'country', 'ge', 'db',
];

Bemerkenswert: external_id ist bewusst ausgeschlossen, das ist bereits der Device-Cookie-Hash und lebt separat, also dupliziert der Tresor ihn nie. Werte werden getrimmt, aber nicht vorgehasht gespeichert, weil jeder Kanal seine eigene Normalisierung zur Hash-Zeit anwendet (mehr dazu unten) und Vorhashen den Wert auf das Format eines einzigen Kanals festnageln würde.

Drei Regeln halten den Tresor ehrlich. Er schreibt nur, wenn das Consent nl_pref.marketing vorhanden ist. Er liest hinter demselben Consent-Gate, sodass ein späterer Consent-Widerruf das Wiedereinspielen gespeicherter PII an die Kanäle stoppt, noch bevor der Cookie natürlich abläuft. Und die TTL ist begrenzt: standardmäßig 30 Tage, konfigurierbar, hart gekappt bei 180, um innerhalb des Aufbewahrungsfensters zu bleiben, das zu den Custom-Audience-Refresh-Zyklen von Meta und Pinterest passt und das EU-Datenschutzbehörden breit als angemessen akzeptieren.

Enrichment läuft einmal, für jeden Kanal, über den Dispatch-Filter

Der Tresor patcht nicht die Dispatch-Methode jedes Kanals. Er hängt sich an den einen Pre-Dispatch-Filter, durch den jedes Event läuft, sodass ein neu hinzugefügter Kanal das Enrichment gratis erbt:

// priority 6: after the commerce-adapter enrich (5), before GeoIP (7)
add_filter( 'bcnr_pre_dispatch_event', [ __CLASS__, 'enrich_event' ], 6 );

public static function enrich_event( $event ): array {
    $vault = self::recall();
    if ( $vault === [] ) return $event;

    $existing = $event['user_data'] ?? [];
    foreach ( $vault as $k => $v ) {
        // gap-fill only: never overwrite a value the event already has
        if ( ! isset( $existing[ $k ] ) || $existing[ $k ] === '' ) {
            $existing[ $k ] = $v;
        }
    }
    $event['user_data'] = $existing;
    return $event;
}

Die Prioritäts-Reihenfolge ist das ganze Design. Commerce-Adapter-Session-Daten (die aktive WooCommerce-Kundin, die EDD-Session, die SureCart-Bestellung) laufen bei Priorität 5 und gewinnen, weil sie die frischeste, autoritativste Quelle sind. Der Tresor läuft bei 6 und füllt nur, was der Adapter leer gelassen hat, weil Tresor-Daten älter sein können. GeoIP läuft bei 7 und füllt nur, was nach beiden noch fehlt, weil eine IP-abgeleitete Stadt eine Schätzung ist und eine vom Besucher angegebene Adresse immer eine Schätzung schlägt. Lücken füllen, nie überschreiben, in einer bewussten Vertrauens-Reihenfolge.

Eine frühere Version machte hier einen subtilen Fehler, der es wert ist, genannt zu werden: die Tresor-Lesevorgänge lebten früher in den Commerce-Adaptern, was bedeutete, dass nur Adapter-geroutete Events sie sahen. Browser-geroutete Pageviews und Click-Tracking-Events bekamen den Tresor nie, sodass eine Besucherin, die ein Formular absandte und dann einen /cart/-Pageview traf, bei Pinterest und Snap immer noch mit nur drei oder vier user_data-Feldern ankam. Das Verschieben des Lesevorgangs auf den globalen Pre-Dispatch-Filter hat es behoben: jetzt bekommt jedes Event den Tresor, egal wie es in die Pipeline gelangt ist.

Gratis-E-Mail-Erfassung aus Newsletter-Links

Ein zusätzlicher Trick reitet auf dem Tresor mit. Ein großer Anteil des Top-of-Funnel-Traffics kommt über E-Mail-Newsletter, und die großen ESPs betten die Adresse der Empfängerin direkt in den Link ein. Beaconry liest sie aus und seedet den Tresor beim Landen, bevor die Besucherin irgendetwas tut:

  • ?email= für Mailchimp und HubSpot, ?em= und ?contact_email= für andere ESP-Templates: Klartext, direkt gelesen.
  • ?_ke= für Klaviyo: URL-safe Base64. Beaconry konvertiert es zurück zu Standard-Base64, padded neu, dekodiert und behandelt sowohl die Plain-E-Mail- als auch die JSON-umhüllte ({"email":"..."}) Form, die Klaviyo ausgibt.

Jeder Kandidat wird mit is_email() validiert, bevor er gespeichert wird, und die Erfassung wird übersprungen, wenn der Tresor bereits eine E-Mail hält (ein veralteter Newsletter-Link sollte nie einen frischeren Form-Submit überschreiben). Der Nettoeffekt: ein Newsletter-Klick gibt jedem Event dieser Session starke Match-Keys, ganz ohne dass die Besucherin PII tippt.

Serverseitiges _fbp und _fbc: den Browser-Cookie ohne Browser pflanzen

Metas Match-Graph stützt sich stark auf zwei seiner eigenen first-party Cookies. _fbp ist ein stabiler Identifier pro Browser, und _fbc ist das Klick-Composite, abgeleitet aus der fbclid, die eine Meta-Anzeige an die Landing-URL anhängt. Normalerweise schreibt nur das Meta-Pixel-JavaScript sie, was heißt, sie existieren nur, wenn das Pixel lief: Marketing-Consent gegeben, kein Script-Blocker, ein ununterbrochener Pfad zu connect.facebook.net. Für eine Adblock-Besucherin scheitern alle drei, und _fbp ist schlicht nie da.

Beaconry generiert beide serverseitig. _fbc wird aus dem fbclid-Query-Parameter in Metas dokumentiertem Format synthetisiert, wenn noch kein Cookie existiert:

// fb.<subdomain_index>.<creation_ms>.<fbclid>
return 'fb.1.' . ( time() * 1000 ) . '.' . $fbclid;

_fbp wird nach derselben Spec generiert, fb.1.<creation_time_ms>.<random_10_digits>, und als echter Cookie zurückgeschrieben, sodass jeder spätere Meta-Pixel-Call (falls die Kundin je den Hybrid-Modus aktiviert) weiter denselben Wert nutzt, statt einen konkurrierenden zu prägen:

$ts_ms     = (int) round( microtime( true ) * 1000 );
$random_10 = (string) random_int( 1000000000, 9999999999 );
$fbp       = 'fb.1.' . $ts_ms . '.' . $random_10;

Dieser Cookie wird mit drei Schutzbedingungen gesetzt, und sie sind wichtig. Er schreibt nur, wenn ein Meta-Ziel konfiguriert ist (has_meta(), sonst hat der Cookie keinen Konsumenten und ist reines Rauschen), nur wenn Marketing-Consent vorhanden ist (ihn sonst zu schreiben würde ePrivacy verletzen) und nur, wenn die Header noch nicht gesendet wurden. Anders als der Tresor wird _fbp absichtlich HttpOnly=false gesetzt: die Meta-Pixel-Helper-Extension und ein künftiges Browser-Pixel müssen beide darauf zugreifen, also kann er nicht vor JavaScript weggesperrt werden. TTL ist 90 Tage, Metas eigener Default.

Das Ergebnis: eine Adblock-Besucherin, die eine Meta-Anzeige geklickt hat, erreicht Meta CAPI jetzt mit einer gültigen fbc (aus ihrer fbclid) und einer stabilen fbp, genau den zwei Cookies, die das Browser-Pixel gesetzt hätte, gepflanzt von einem Same-Origin-Request, den der Adblocker nicht entfernen kann.

GeoIP: Standort für die wirklich Anonymen füllen

Für eine Besucherin ohne E-Mail, ohne Bestellung und ohne Tresor-Historie ist der Standort immer noch aus ihrer IP rekonstruierbar, und Stadt plus Bundesland plus PLZ plus Land ist ein bedeutsamer Match-Quality-Beitrag. Beaconry löst ihn aus zwei Quellen auf, der Reihe nach:

  1. Cloudflare-Edge-Header. Jede Site hinter Cloudflare bekommt CF-IPCountry, CF-IPCity, CF-Region-Code und CF-Postal-Code bei jedem Request, am Edge aufgelöst, gratis und immun gegen client-seitige Tracking-Prevention. Beaconry verwirft Cloudflares Tor-Sentinels (XX, T1), damit ein anonymisierender Exit-Node das Country-Feld nicht vergiftet.
  2. WooCommerce-Geolocation als Fallback, wenn Cloudflare fehlt. WC_Geolocation::geolocate_ip() nutzt WooCommerces gepflegte MaxMind-Datenbank für Land und Bundesland.

Wie der Tresor füllt GeoIP nur Lücken. Es läuft bei Priorität 7, als Letztes in der Kette, und berührt ein Feld nur, wenn es nach Adapter und Tresor noch leer ist. Eine vom Besucher angegebene Stadt schlägt immer eine IP-geschätzte. Der Lookup wird zudem in einem Request-Static memoisiert, sodass ein einzelnes Event, das auf zehn Kanäle ausfächert, die Geo-Daten einmal auflöst, nicht zehnmal.

Kanonisierung: dieselbe Person, derselbe Hash, jedes Mal

Nichts davon hilft, wenn derselbe logische Wert in drei Schreibweisen ankommt und zu drei verschiedenen Strings hasht. Ein deutsches Bundesland kann als "Niedersachsen", "NI" oder "ni" auftauchen. SHA-256 macht daraus drei unverbundene Hashes, und der Audience-Matcher der Plattform gruppiert sie nie wieder zusammen. Die Match-Daten sind technisch vorhanden und faktisch nutzlos.

Also kanonisiert Beaconry die format-sensitiven Felder vor dem Hashen. Das läuft innerhalb von hash_user_data(), direkt vor dem SHA-256-Durchlauf, für jeden Kanal:

  • country normalisiert auf ISO-3166-1-alpha-2 in Kleinschreibung. Eine große Lookup-Tabelle mappt Alpha-3-Codes sowie englische, deutsche und spanische Ländernamen ("Deutschland", "Germany", "Alemania", "deu" fallen alle auf de zusammen).
  • state normalisiert auf den ISO-3166-2-Subdivision-Code, in Kleinschreibung, mit abgestreiftem Country-Prefix. Cloudflare gibt Bundesländer als DE-NI aus; Meta und TikTok wollen nur ni. Das Country-Prefix dient zugleich als Disambiguierungs-Hinweis, denn ni ist sowohl Niedersachsen im State-Slot als auch Nigeria im Country-Slot.
  • zip ist nur Ziffern für die DACH-Region und den Großteil der EU, aber alphanumerisch für UK, Kanada, Niederlande und Irland, deren Postleitzahlen legitim Buchstaben enthalten ("SW1A 1AA", "M5V 3L9").
  • db (Geburtsdatum) normalisiert auf YYYYMMDD ohne Trennzeichen, parst jedes Eingabeformat via DateTime.

Der Lohn: die Form-Submit-E-Mail, die WooCommerce-Bestelladresse, der aus dem Tresor erinnerte Name, die GeoIP-Stadt und die aus dem Newsletter erfasste E-Mail laufen alle auf eine kanonische, identisch gehashte Identität pro Person zusammen. Diese Konvergenz ist es, die die Match-Quality-Nadel tatsächlich bewegt, weil die Plattform endlich einen konsistenten Fingerabdruck sieht statt eines Schmiers von Beinahe-Duplikaten.

Das gehashte Feld-Set pro Kanal

"Hash die PII und sende sie" ist nicht eine Regel, es ist eine andere Regel pro Kanal, und die kanalspezifische Form falsch zu treffen kippt die Match-Rate still, selbst wenn die Daten korrekt sind. Beaconrys Hashing-Helfer nimmt ein kanonisches Set, und jeder Dispatcher formt es auf die Spec seines Kanals um:

  • Meta CAPI hasht das volle Set: em, ph, fn, ln, ct, st, zp, country, ge, db und external_id. Das Telefon wird von seinem führenden + auf reine Ziffern befreit, weil Metas Audience-Graph diese Form matcht. client_ip_address, client_user_agent, fbc und fbp reiten roh mit, nie gehasht, gemäß Spec.
  • TikTok Events API ist beim Standort das bewusste Gegenteil: Stadt, Bundesland und Land gehen ungehasht (nur in Kleinschreibung und getrimmt), während email, phone, first_name, last_name, zip_code und external_id gehasht werden. Das Telefon behält hier das + (E.164), umgekehrt zu Meta. TikTok eine gehashte Stadt zu senden ist ein stiller Match-Fehler, das Doc-First-Audit hat genau das gefangen.
  • Pinterest, Snap und Reddit nehmen die gehashten Felder in einelementige Arrays verpackt. Sie teilen eine Fallback-Regel: wenn keine external_id durch die PII-Pipeline kam, liefert Beaconry die gehashte Device-ID (den nl_dev-Cookie oder einen IP-plus-User-Agent-Composite-Hash), sodass selbst ein anonymer Seitenbesuch einen stabilen matchbaren Key trägt. Pinterests "external_id missing"-Best-Practice-Warnung war der rauchende Colt, der das angetrieben hat.
  • GA4 für User-Provided-Data nutzt eigene Key-Namen, sha256_email_address und sha256_phone_number, gehasht mit derselben Trim-Lowercase-SHA-256-Normalisierung, damit die Werte zu dem passen, was ein Browser-gtag gesendet hätte.

Der eine Kern aus Kanonisieren-dann-Hashen garantiert, dass der Wert über alle Kanäle identisch ist; jeder Dispatcher besitzt nur die Wire-Format-Eigenheit (Array-Verpackung, Key-Umbenennung, das Telefon-+, die Hash-oder-nicht-Entscheidung für den Standort). Diese Trennung ist der Grund, warum ein Wert, der bei Meta matcht, auch bei TikTok matcht, statt pro Kanal abzudriften.

Alles zusammengesetzt: ein anonymes Event

Verfolge einen einzelnen Pageview einer Adblock-Besucherin, die gestern eine Meta-Anzeige geklickt und heute früher ein Kontaktformular abgesandt hat, alles im selben Browser:

  1. Das Event betritt die Pipeline mit fast nichts: einer Page-URL, einem Timestamp, einem Device-ID-Cookie.
  2. Priorität 5, der Commerce-Adapter, hat nichts beizutragen (das ist kein Shop-Event).
  3. Priorität 6, der Tresor, erinnert sich an die E-Mail, die die Besucherin heute Morgen ins Formular getippt hat, und fügt sie hinzu.
  4. Priorität 7, GeoIP, füllt Stadt, Bundesland und PLZ aus den Cloudflare-Edge-Headern.
  5. Beim Dispatch wird _fbc aus der gestrigen fbclid synthetisiert (noch in der URL-Kette oder im Cookie), und _fbp wird generiert und gepflanzt, kein Browser-Pixel nötig.
  6. Die Kanonisierung kollabiert das Bundesland auf ni und das Land auf de, dann hasht SHA-256 das Ganze in Metas exakter Form.

Das Event, das mit drei schwachen Signalen begann, verlässt die Pipeline mit gehashter E-Mail, Name wo verfügbar, gehashter Stadt, Bundesland, PLZ, Land, einer gültigen fbc, einer stabilen fbp, plus roher IP und User-Agent. Das ist der Unterschied zwischen einer "Poor"- und einer "Good"-Event-Match-Quality-Note, erreicht auf einem anonymen Pageview, von einer Besucherin, deren Browser-Pixel nie geladen hat.

Fazit

Match-Quality ist kein Credential, das du einmal einfügst. Sie ist die Pro-Event-Summe identifizierender Signale, und die Events, die sie am meisten brauchen, sind die anonymen, die das naive Setup nahezu leer sendet. Beaconry behandelt sie als serverseitiges Pipeline-Problem: ein consent-gegateter, verschlüsselter first-party Tresor trägt Identifier über die gesamte Reise einer Besucherin vorwärts, die serverseitige Generierung von _fbp und _fbc pflanzt die Cookies, die ein geblocktes Pixel gesetzt hätte, GeoIP füllt den Standort für die wirklich Anonymen, und strikte Kanonisierung sorgt dafür, dass dieselbe Person bei jedem Kanal zum selben Wert hasht. Das Schlagzeilen-Ergebnis für einen Advertiser, der Event Match Quality jagt: du hebst sie für die Adblock-Besucher, die ein Browser-Pixel nicht erreicht, ganz ohne ein Browser-Pixel zu laden.