Lifting CAPI match quality from the server
Event Match Quality is a count of how many identifying signals reach the platform per event. Most server-side setups send three or four and stop there. Beaconry does the match-key work on the server instead: a first-party encrypted vault that remembers identifiers across events, GeoIP and _fbp enrichment that fills the gaps a browser pixel would normally fill, and strict canonicalization so the same person hashes to the same value every time. No browser pixel required.
What match quality actually counts
Meta's Event Match Quality, TikTok's matched-event rate, Pinterest's match-rate score: they are all the same idea wearing different names. The platform receives an event, tries to tie it to a real person in its graph, and grades you on how many usable signals you handed over. Hashed email matches strongly. Hashed phone matches strongly. First name plus last name plus city plus zip narrow it further. A click-ID (fbc, ttclid) or a first-party browser cookie (_fbp) anchors the event to a known browser. IP and user agent are weak on their own but raise confidence in combination.
The naive server-side setup sends what WordPress happens to have lying around at the moment the event fires. On a checkout, that is a lot: the customer typed their email, phone, name, and address into the order form. On a plain pageview from an ad click, it is almost nothing, because the visitor has not handed over anything yet. So the events that matter most for optimisation, the top-of-funnel ones the platform uses to learn who to show your ad to, arrive with the thinnest match data. That is the gap this work closes.
The browser-pixel answer to this is well known: let Meta's or TikTok's own JavaScript set its own first-party cookies and read them back. That works, but it reintroduces the third-party script that adblockers strip, costs bytes, and breaks the "no third-party tracking" posture. Hybrid mode covers the cases where that trade is worth it (we wrote a whole post on when to switch it on). Everything below is what Beaconry does before you reach for hybrid mode, entirely server-side, for the visitors a blocked pixel would have left empty.
The PII vault: remembering identifiers across events
The core problem with anonymous top-of-funnel events is timing. A visitor clicks a Meta ad, lands on a blog post (pageview, no PII), reads it, comes back two days later from a Google search, browses three products (three view_item events, still no PII), and only on the fourth visit fills in a contact form. The form submit has a strong email. The eleven events before it had nothing. Without persistence, those eleven events are forever low-match, and the platform never learns that the person who eventually converted is the same person who clicked the ad.
Beaconry's PII vault fixes the asymmetry. When any event carries a real identifier, an email from a form, a name and address from a WooCommerce order, the vault stores it. Every subsequent event reads it back and gets enriched with those same match keys. The form-submit email travels forward to every later pageview and product view from that browser.
The storage is a single first-party cookie named nl_v. It is not a plaintext cookie. The payload is the identifier set, JSON-encoded, then AES-256-GCM encrypted with a key derived from the site's own wp_salt('auth'):
// 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 );The browser only ever sees base64 of authenticated ciphertext. It cannot read the email back, and it cannot tamper with it (GCM is authenticated, a flipped byte fails the tag check on decrypt and the vault treats it as absent). Under GDPR Article 4(5) that makes the cookie a pseudonymised store: useless without the server-side key that never leaves the server. The cookie is set HttpOnly (no JavaScript access), SameSite=Lax (no cross-site leakage), and Secure on HTTPS.
Ten fields are eligible for the vault, exactly the set the CAPI hashing pipeline recognises:
private const VAULT_FIELDS = [
'em', 'ph', 'fn', 'ln', 'ct', 'st', 'zp', 'country', 'ge', 'db',
];Notably external_id is deliberately excluded, that one is already the device-cookie hash and lives separately, so the vault never duplicates it. Values are stored trimmed but not pre-hashed, because each channel applies its own normalisation at hash time (more on that below) and pre-hashing would lock the value into one channel's format.
Three rules keep the vault honest. It only writes when nl_pref.marketing consent is present. It reads behind the same consent gate, so a later consent withdrawal stops replaying stored PII to the channels even before the cookie naturally expires. And the TTL is bounded: default 30 days, configurable, hard-capped at 180 to stay inside the retention window that matches Meta and Pinterest custom-audience refresh cycles and that EU data-protection authorities broadly accept as reasonable.
Enrichment runs once, for every channel, via the dispatch filter
The vault does not patch each channel's dispatch method. It hooks the single pre-dispatch filter that every event passes through, so a newly added channel inherits the enrichment for free:
// 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;
}The priority ordering is the whole design. Commerce-adapter session data (the live WooCommerce customer, the EDD session, the SureCart order) runs at priority 5 and wins, because it is the freshest, most authoritative source. The vault runs at 6 and fills only what the adapter left empty, because vault data may be older. GeoIP runs at 7 and fills only what is still missing after both, because an IP-derived city is a guess and a visitor-declared address always beats a guess. Gap-fill, never overwrite, in a deliberate order of trust.
An earlier version made a subtle mistake here worth calling out: the vault reads used to live inside the commerce adapters, which meant only adapter-routed events saw them. Browser-routed pageviews and click-tracking events never got the vault, so a visitor who submitted a form and then hit a /cart/ pageview still arrived at Pinterest and Snap with only three or four user_data fields. Moving the read to the global pre-dispatch filter fixed it: now every event gets the vault, regardless of how it entered the pipeline.
Free email capture from newsletter links
One extra trick rides on the vault. A large share of top-of-funnel traffic arrives through email newsletters, and the major ESPs embed the recipient's address right in the link. Beaconry reads it and seeds the vault on landing, before the visitor does anything:
?email=for Mailchimp and HubSpot,?em=and?contact_email=for other ESP templates: plaintext, read directly.?_ke=for Klaviyo: URL-safe base64. Beaconry converts it back to standard base64, re-pads, decodes, and handles both the plain-email and the JSON-wrapped ({"email":"..."}) shapes Klaviyo emits.
Every candidate is validated with is_email() before it is stored, and capture is skipped if the vault already holds an email (a stale newsletter link should never clobber a fresher form submit). The net effect: a newsletter click gives every event in that session strong match keys, with zero PII typed by the visitor.
Server-side _fbp and _fbc: planting the browser cookie without the browser
Meta's match graph leans hard on two of its own first-party cookies. _fbp is a stable per-browser identifier, and _fbc is the click composite derived from the fbclid a Meta ad appends to the landing URL. Normally only the Meta Pixel JavaScript writes them, which means they exist only when the pixel ran: marketing consent given, no script blocker, an unbroken path to connect.facebook.net. For an adblock visitor, all three of those fail and _fbp is simply never there.
Beaconry generates both server-side. _fbc is synthesised from the fbclid query parameter in Meta's documented format when no cookie exists yet:
// fb.<subdomain_index>.<creation_ms>.<fbclid>
return 'fb.1.' . ( time() * 1000 ) . '.' . $fbclid;_fbp is generated to the same spec, fb.1.<creation_time_ms>.<random_10_digits>, and written back as a real cookie so any later Meta Pixel call (if the customer ever enables hybrid mode) keeps using the same value instead of minting a competing one:
$ts_ms = (int) round( microtime( true ) * 1000 );
$random_10 = (string) random_int( 1000000000, 9999999999 );
$fbp = 'fb.1.' . $ts_ms . '.' . $random_10;This cookie is set with three guard conditions, and they matter. It writes only when there is a Meta destination configured (has_meta(), otherwise the cookie has no consumer and is pure noise), only when marketing consent is present (writing it otherwise would breach ePrivacy), and only when headers have not been sent yet. Unlike the vault, _fbp is set HttpOnly=false on purpose: the Meta Pixel Helper extension and a future browser pixel both need to read it, so it cannot be locked away from JavaScript. TTL is 90 days, Meta's own default.
The result: an adblock visitor who clicked a Meta ad now reaches Meta CAPI with a valid fbc (from their fbclid) and a stable fbp, the exact two cookies the browser pixel would have set, planted by a same-origin request the adblocker cannot strip.
GeoIP: filling location for the truly anonymous
For a visitor with no email, no order, and no vault history, location is still recoverable from their IP, and city plus state plus zip plus country is a meaningful match-quality contribution. Beaconry resolves it from two sources, in order:
- Cloudflare edge headers. Any site behind Cloudflare gets
CF-IPCountry,CF-IPCity,CF-Region-Code, andCF-Postal-Codeon every request, resolved at the edge, free, and immune to client-side tracking-prevention. Beaconry rejects Cloudflare's Tor sentinels (XX,T1) so an anonymising exit node does not poison the country field. - WooCommerce geolocation as the fallback when Cloudflare is absent.
WC_Geolocation::geolocate_ip()uses WooCommerce's maintained MaxMind database for country and state.
Like the vault, GeoIP gap-fills only. It runs at priority 7, last in the chain, and touches a field only if it is still empty after the adapter and the vault have had their turn. A visitor-declared city always beats an IP-guessed one. The lookup is also memoised in a request-static, so a single event fanning out to ten channels resolves geo once, not ten times.
Canonicalization: the same person, the same hash, every time
None of the above helps if the same logical value arrives in three spellings and hashes to three different strings. A German state can show up as "Niedersachsen", "NI", or "ni". SHA-256 turns those into three unrelated hashes, and the platform's audience matcher never groups them back together. The match data is technically present and effectively useless.
So Beaconry canonicalizes the format-sensitive fields before hashing. This runs inside hash_user_data(), immediately before the SHA-256 pass, for every channel:
- country normalises to ISO-3166-1 alpha-2 lowercase. A large lookup table maps alpha-3 codes and English, German, and Spanish country names ("Deutschland", "Germany", "Alemania", "deu" all collapse to
de). - state normalises to the ISO-3166-2 subdivision code, lowercase, with the country prefix stripped. Cloudflare hands states out as
DE-NI; Meta and TikTok want justni. The country prefix doubles as a disambiguation hint, becauseniis both Lower Saxony in the state slot and Nigeria in the country slot. - zip is digits-only for the DACH region and most of the EU, but kept alphanumeric for the UK, Canada, Netherlands, and Ireland, whose postal codes legitimately contain letters ("SW1A 1AA", "M5V 3L9").
- db (date of birth) normalises to
YYYYMMDDwith no separators, parsing whatever input format viaDateTime.
The payoff is that the form-submit email, the WooCommerce order address, the vault-recalled name, the GeoIP city, and the newsletter-captured email all converge on one canonical, identically-hashed identity per person. That convergence is what actually moves the match-quality needle, because the platform finally sees a consistent fingerprint instead of a smear of near-duplicates.
The per-channel hashed-field set
"Hash the PII and send it" is not one rule, it is a different rule per channel, and getting the per-channel shape wrong silently tanks the match rate even when the data is correct. Beaconry's hashing helper takes a canonical set and each dispatcher re-shapes it to its channel's spec:
- Meta CAPI hashes the full set:
em,ph,fn,ln,ct,st,zp,country,ge,db, andexternal_id. Phone is stripped of its leading+to digits-only, because Meta's audience graph matches that shape.client_ip_address,client_user_agent,fbc, andfbpride along raw, never hashed, per spec. - TikTok Events API is the deliberate opposite for location: city, state, and country go unhashed (lowercase and trimmed only), while
email,phone,first_name,last_name,zip_code, andexternal_idare hashed. Phone keeps the+(E.164) here, the reverse of Meta. Sending TikTok a hashed city is a silent match failure, the doc-first audit caught exactly this. - Pinterest, Snap, and Reddit take the hashed fields wrapped in single-element arrays. They share one fallback rule: if no
external_idmade it through the PII pipeline, Beaconry supplies the hashed device-id (thenl_devcookie, or an IP-plus-user-agent composite hash) so even an anonymous page-visit carries one stable matchable key. Pinterest's "external_id missing" best-practice warning was the smoking gun that drove this. - GA4 for user-provided data uses its own key names,
sha256_email_addressandsha256_phone_number, hashed with the same trim-lowercase-SHA-256 normalisation so the values line up with what a browser gtag would have sent.
The single canonicalization-then-hash core guarantees the value is identical across channels; each dispatcher owns only the wire-format quirk (array wrapping, key renaming, the phone +, the hash-or-not decision for location). That separation is why a value that matches at Meta also matches at TikTok, instead of drifting per channel.
Putting it together: one anonymous event
Trace a single pageview from an adblock visitor who clicked a Meta ad yesterday and submitted a contact form earlier today, all on the same browser:
- The event enters the pipeline with almost nothing: a page URL, a timestamp, a device-id cookie.
- Priority 5, the commerce adapter, has nothing to add (this is not a shop event).
- Priority 6, the vault, recalls the email the visitor typed into this morning's form and adds it.
- Priority 7, GeoIP, fills city, state, and zip from the Cloudflare edge headers.
- At dispatch,
_fbcis synthesised from yesterday'sfbclid(still in the URL chain or cookie), and_fbpis generated and planted, no browser pixel needed. - Canonicalization collapses the state to
niand the country tode, then SHA-256 hashes the lot in Meta's exact shape.
The event that started with three weak signals leaves with hashed email, name where available, hashed city, state, zip, country, a valid fbc, a stable fbp, plus raw IP and user agent. That is the difference between a "Poor" and a "Good" Event Match Quality grade, achieved on an anonymous pageview, from a visitor whose browser pixel never loaded.
Take-away
Match quality is not a credential you paste in once. It is the per-event sum of identifying signals, and the events that need it most are the anonymous ones the naive setup sends nearly empty. Beaconry treats it as a server-side pipeline problem: a consent-gated, encrypted first-party vault carries identifiers forward across a visitor's whole journey, server-side _fbp and _fbc generation plants the cookies a blocked pixel would have set, GeoIP fills location for the truly anonymous, and strict canonicalization makes sure the same person hashes to the same value at every channel. The headline result for an advertiser chasing Event Match Quality: you lift it for the adblock visitors a browser pixel cannot reach, without loading a browser pixel at all.