Meta Event Match Quality Is Low
Almost always one of two things: you are sending too few customer parameters, or you are hashing them without normalising first. Both are invisible in the interface and both are fixable in an afternoon.
Get My Free Audit →Are you seeing this?
- Events Manager shows a low or poor Event Match Quality rating on your Purchase event
- Cost per acquisition has drifted upward with no change to creative or targeting
- Custom audiences built from website events are smaller than your traffic suggests they should be
- The Conversions API is reporting events successfully but performance has not improved
- Your parameter coverage percentages in Events Manager show gaps you cannot explain
Why it happens
You are sending too few parameters.
Many implementations send email and nothing else. Match quality is driven by how many independent identifiers Meta can use, and each additional one — phone, external_id, fbc, name, location — measurably raises the ceiling. An email-only payload cannot score well no matter how correct it is.
Values are hashed without being normalised first.
John@Example.com and john@example.com produce entirely different SHA-256 hashes. Trailing whitespace, capital letters, unformatted phone numbers, and accented characters all silently break matching. The API accepts the hash and reports success, because a hash is a hash — it has no way to know yours is wrong.
external_id is missing or not stable.
A per-session random value is worthless. external_id is only useful when it identifies the same person across sessions and devices, which means it must be derived from something durable — a customer ID, or a persistent identifier stored in a first-party cookie.
fbc is never captured or is lost before checkout.
The click ID arrives as fbclid on the landing page. If it is not captured and persisted, it is gone by the time the purchase fires — and fbc is one of the strongest matching signals available on paid traffic specifically.
Hashing is applied incorrectly.
Double-hashing an already-hashed value, hashing fields that should be sent in plaintext, or hashing on both browser and server with different implementations. Each produces a valid-looking payload that matches nothing.
How to diagnose it
Step 01 — Read the parameter breakdown, not just the score.
In Events Manager, open your Purchase event and look at the customer information parameters section. It lists each parameter and the percentage of events that included it. This tells you exactly which fields are missing rather than leaving you to guess from a single score.
Step 02 — Look for parameters at low coverage.
A parameter present on 12% of events is usually worse than useless — it suggests it is being captured only in one narrow path, which is often a sign the implementation is inconsistent between browser and server.
Step 03 — Verify your normalisation with a known value.
Take a real test order. Hash the email yourself using the normalisation rules below, and compare against what your implementation sent. If they differ, normalisation is your problem and nothing else matters until it is fixed.
Step 04 — Check fbc coverage specifically on paid traffic.
Overall fbc coverage will always be low, because organic visitors have no click ID. Segment to paid traffic. If coverage there is also low, you are losing the click ID somewhere between landing and checkout.
Step 05 — Compare the browser and server payloads for the same event.
Use the Test Events tool and complete one purchase. Both payloads should carry the same identifiers, normalised the same way. Divergence here is common and quietly halves your effective match rate.
How to fix it
Normalise before hashing. These rules are not optional and they are where most implementations fail:
| Field | Normalisation | Then |
|---|---|---|
| em (email) | trim whitespace, lowercase | SHA-256 |
| ph (phone) | digits only, include country code, strip +, spaces, dashes, leading zeros | SHA-256 |
| fn / ln (name) | trim, lowercase, strip punctuation, UTF-8 | SHA-256 |
| ct (city) | lowercase, strip spaces and punctuation | SHA-256 |
| st (state) | lowercase, two-character code where applicable | SHA-256 |
| zp (postcode) | lowercase, strip spaces | SHA-256 |
| country | lowercase two-letter ISO code | SHA-256 |
| external_id | your stable identifier, applied consistently | hashed or plain — but never mixed |
| fbc | format fb.1.<timestamp>.<fbclid> | send plain, never hash |
| fbp | format fb.1.<timestamp>.<random> | send plain, never hash |
| client_ip_address | as received | send plain, never hash |
| client_user_agent | as received | send plain, never hash |
The last four are the ones most often hashed by mistake. Hashing them destroys them.
function propulse_normalise_and_hash( $value, $type = 'text' ) {
if ( empty( $value ) ) {
return '';
}
$value = trim( (string) $value );
switch ( $type ) {
case 'email':
$value = strtolower( $value );
break;
case 'phone':
// Digits only. Country code included, no leading zeros.
$value = preg_replace( '/[^0-9]/', '', $value );
$value = ltrim( $value, '0' );
break;
case 'name':
case 'city':
$value = strtolower( $value );
$value = preg_replace( '/[^a-z\x{00C0}-\x{024F}]/u', '', $value );
break;
case 'postcode':
$value = strtolower( preg_replace( '/\s+/', '', $value ) );
break;
case 'country':
case 'state':
$value = strtolower( substr( preg_replace( '/[^a-zA-Z]/', '', $value ), 0, 2 ) );
break;
default:
$value = strtolower( $value );
}
if ( '' === $value ) {
return '';
}
return hash( 'sha256', $value );
}Building the full payload from a WooCommerce order:
function propulse_capi_user_data( WC_Order $order ) {
$data = array(
'em' => propulse_normalise_and_hash( $order->get_billing_email(), 'email' ),
'ph' => propulse_normalise_and_hash( $order->get_billing_phone(), 'phone' ),
'fn' => propulse_normalise_and_hash( $order->get_billing_first_name(), 'name' ),
'ln' => propulse_normalise_and_hash( $order->get_billing_last_name(), 'name' ),
'ct' => propulse_normalise_and_hash( $order->get_billing_city(), 'city' ),
'st' => propulse_normalise_and_hash( $order->get_billing_state(), 'state' ),
'zp' => propulse_normalise_and_hash( $order->get_billing_postcode(), 'postcode' ),
'country' => propulse_normalise_and_hash( $order->get_billing_country(), 'country' ),
'external_id' => propulse_normalise_and_hash( propulse_stable_external_id( $order ), 'text' ),
);
// Sent in plain text — hashing these makes them unusable.
$fbc = $order->get_meta( '_propulse_fbc' );
$fbp = $order->get_meta( '_propulse_fbp' );
if ( $fbc ) {
$data['fbc'] = $fbc;
}
if ( $fbp ) {
$data['fbp'] = $fbp;
}
$data['client_ip_address'] = $order->get_customer_ip_address();
$data['client_user_agent'] = $order->get_customer_user_agent();
// Meta rejects empty strings — remove rather than send blank.
return array_filter( $data, function ( $v ) {
return '' !== $v && null !== $v;
} );
}
function propulse_stable_external_id( WC_Order $order ) {
$user_id = $order->get_user_id();
if ( $user_id ) {
return 'uid_' . $user_id;
}
// Guest checkout — derive from email so the same person matches across orders.
return 'em_' . strtolower( trim( $order->get_billing_email() ) );
}Note the guest-checkout handling in propulse_stable_external_id(). Deriving the identifier from the email means a guest who orders three times over a year is recognised as one person rather than three, which is exactly what external_id exists to do and what a session-scoped value fails to achieve.
For fbc capture, see our fix on server-side cookies not persisting — the click ID has to survive the journey before any of this matters.
How to verify the fix worked
- Complete a test order and inspect the payload in the Test Events tool — confirm every parameter you expect is present and non-empty
- Confirm fbc, fbp, IP and user agent are unhashed
- Check the parameter coverage percentages in Events Manager after 24 hours
- Event Match Quality updates on a delay — allow at least seven days before judging the result, and compare like-for-like periods
- Watch cost per acquisition over the following two to four weeks; matching improvements show up in delivery before they show up in reporting
When this needs more than a snippet
If your parameters are complete and correctly normalised and match quality is still poor, the problem is usually upstream: the identifiers are not reaching your server in the first place, because the click ID is lost at some point in the funnel or your checkout drops customer data between steps.
That is a funnel-tracing job rather than a code fix, and it is what our Meta CAPI implementation work covers.
Tell us about your setup. We respond within 24 hours.
Common
Questions
Meta rates matching on a scale from poor to great, and the practical target depends on your traffic mix — a business with mostly logged-in returning customers can reach scores a guest-checkout store never will. Rather than chasing an absolute number, track your own score over time and after each change. Movement is the signal.
Not if they are hashed correctly, which is precisely why Meta requires SHA-256 on personal fields. The real risk is the opposite mistake — sending an unhashed email address, which is both a policy violation and a GDPR problem. Confirm you have a lawful basis for the processing, and never hash fbc, fbp, IP, or user agent, which are designed to be sent as-is.
It improves the input to Meta's optimisation, which usually improves delivery — but the size of the effect depends entirely on how poor the matching was. Moving from an email-only payload to a full parameter set is often substantial. Moving from good to slightly better is marginal. The audit quantifies your starting point before you commit to anything.
Ready to Build Something That Works?
Book a free 30-minute strategy audit. No pitch deck, no pressure — just an honest look at your setup and what to fix first.
