Field Fix

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

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
Causes

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.

Diagnose

How to diagnose it

01

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.

02

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.

03

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.

04

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.

05

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.

Fix

How to fix it

Normalise before hashing. These rules are not optional and they are where most implementations fail:

FieldNormalisationThen
em (email)trim whitespace, lowercaseSHA-256
ph (phone)digits only, include country code, strip +, spaces, dashes, leading zerosSHA-256
fn / ln (name)trim, lowercase, strip punctuation, UTF-8SHA-256
ct (city)lowercase, strip spaces and punctuationSHA-256
st (state)lowercase, two-character code where applicableSHA-256
zp (postcode)lowercase, strip spacesSHA-256
countrylowercase two-letter ISO codeSHA-256
external_idyour stable identifier, applied consistentlyhashed or plain — but never mixed
fbcformat fb.1.<timestamp>.<fbclid>send plain, never hash
fbpformat fb.1.<timestamp>.<random>send plain, never hash
client_ip_addressas receivedsend plain, never hash
client_user_agentas receivedsend plain, never hash

The last four are the ones most often hashed by mistake. Hashing them destroys them.

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

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

Verify

How to verify the fix worked

  1. Complete a test order and inspect the payload in the Test Events tool — confirm every parameter you expect is present and non-empty
  2. Confirm fbc, fbp, IP and user agent are unhashed
  3. Check the parameter coverage percentages in Events Manager after 24 hours
  4. Event Match Quality updates on a delay — allow at least seven days before judging the result, and compare like-for-like periods
  5. Watch cost per acquisition over the following two to four weeks; matching improvements show up in delivery before they show up in reporting
Escalation

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.

Get Your Free Audit

Tell us about your setup. We respond within 24 hours.

FAQ

Common
Questions

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.

Get My Free Audit →