Skip to content
← ALL WRITING

2026-04-22 / 13 MIN READ

Pushing Meta CAPI match quality score from 6 to 9

A tutorial on meta capi match quality score improvement. The field-by-field moves that took a DTC Shopify store from a 6 to a 9 in a production rebuild.

The score that actually moves Meta's ad algorithm is match quality, not match rate. On the Q2 2024 rebuild I keep returning to, match quality climbed from around a 6 to a 9.1 in 48 hours. The move came almost entirely from five PII fields and one normalization habit.

If your meta capi match quality score is stuck in the 6 to 7 range, this tutorial walks the exact sequence I used. No magic. Just fields, in order, with the parts that usually get skipped called out.

What the match quality score actually measures

Meta reports match quality per event type in Events Manager, on a 1 to 10 scale. It is Meta's estimate of how confidently it can match a given server event to a real Facebook or Instagram user. A 10 means nearly every event ties cleanly to a known person. A 5 means Meta is guessing on half of them.

Two things drive the score. How many PII fields you send, and how clean those fields are when they arrive. Match rate (a separate metric) is the percentage of events where Meta found a user at all. Quality is the confidence of those matches.

A brand with a 6 match quality is leaking optimization signal on every ad dollar. Meta falls back on modeled conversions, which smooth out the lumpier real-world cohorts your campaigns are actually reaching.

6.0 / 10
6.07.08.09.0target 9.0emphexternal_idfbp + fbcaddress
step 0Baseline

Pixel-only era. Email sometimes, phone rarely, nothing normalized.

Each PII field layered onto the CAPI payload. Scores are directional, drawn from a Q2 2024 DTC Shopify rebuild.

Start here: what a 6 usually means

A 6 almost always means you are sending em (hashed email) and not much else. Sometimes a hashed phone that was never normalized to E.164. Sometimes fbp but not fbc. Address fields completely missing.

Pull an event from Events Manager, hit View Details, and inspect the user_data block. If all you see is em and maybe fbp, you are in the 6 to 7 band by default. The gap to 9 is almost entirely more fields, correctly normalized, sent on every event.

I will walk the five fields in the order that produces the steepest climb.

Step 1: Fix email normalization before anything else

Email is the single biggest lever. On the rebuild I reference throughout, email alone took the score from roughly 6 to 7.

Meta's hashing contract is strict. Lowercase, trim whitespace, then SHA-256. If you pass Michael@Example.com (note trailing space, mixed case), the hash will not match any hash Meta has on file, even for a customer who signed up with that exact address.

import { createHash } from "node:crypto";

function hashEmail(raw: string | null | undefined): string | undefined {
  if (!raw) return undefined;
  const normalized = raw.trim().toLowerCase();
  if (!normalized.includes("@")) return undefined;
  return createHash("sha256").update(normalized).digest("hex");
}

Three common failures. One, you send the raw email by mistake and Meta silently drops it (CAPI requires pre-hashed PII). Two, you hash without normalizing, so capitalization differences break the match. Three, you fetch email from the Shopify order object but do not fall back to order.customer.email for guest checkouts where the top-level email is populated differently.

Audit your own handler with a test email you control. Fire the event, then check Events Manager's Test Events view. If em does not show as "matched" after a few minutes, normalization is off.

Step 2: Add phone (E.164) with fallback logic

Phone is the second-biggest lever, and the one most teams get wrong.

Meta wants E.164 format: plus sign, country code, number. No spaces. No dashes. +14155552671 matches. (415) 555-2671 does not, because your hash of a US-formatted string does not match Meta's hash of the E.164 version.

Shopify stores phone numbers inconsistently. Sometimes in order.billing_address.phone, sometimes in order.shipping_address.phone, sometimes on order.customer.phone, sometimes nowhere. Walk the order in that order and accept the first non-empty value.

function toE164(raw: string | null | undefined, defaultCountry = "1"): string | undefined {
  if (!raw) return undefined;
  const digits = raw.replace(/\D/g, "");
  if (digits.length === 0) return undefined;
  // If already includes country code (11+ digits for US), use as-is.
  if (digits.length === 11 && digits.startsWith(defaultCountry)) {
    return `+${digits}`;
  }
  if (digits.length === 10) {
    return `+${defaultCountry}${digits}`;
  }
  // Already international, or unrecognized. Pass through.
  return `+${digits}`;
}

function hashPhone(raw: string | null | undefined): string | undefined {
  const e164 = toE164(raw);
  if (!e164) return undefined;
  return createHash("sha256").update(e164).digest("hex");
}

Step 3: Pass external_id on every event

external_id is the field that separates a 7.5 from an 8.3. Most stores do not send it, even on rebuilds that checked every other box.

It is your internal customer ID, hashed. On Shopify, that is order.customer.id or order.customer.admin_graphql_api_id. Meta uses it to tie repeat events to the same customer across sessions, devices, and browsers. It acts like an anchor that stabilizes matching even when email and phone vary.

function hashExternalId(raw: string | number | null | undefined): string | undefined {
  if (!raw) return undefined;
  const str = String(raw).trim();
  if (!str) return undefined;
  return createHash("sha256").update(str).digest("hex");
}

Two gotchas. First, pass it on every event type, not just Purchase. ViewContent and AddToCart events benefit from it too, and the per-event-type match quality score drops fast on the pixel-side events if they are missing external_id. Second, for guest checkouts there is no customer ID. Synthesize a stable one from the email address (sha256(email) works as a proxy) so the field is populated rather than null.

Step 4: Fill in fbp and fbc from first-party cookies

fbp (the Facebook browser ID cookie) and fbc (the click ID cookie) are the server-side echo of the browser tracking state. Pass them through on every event and the match quality climbs from around 8.3 to 8.7.

The cookies live on the client. Your server only sees them if the browser sends them up in the request headers, or if your GTM web container reads them and forwards them to the server container. On a standard Stape setup, this is automatic as long as you configured a custom loader domain so the cookies are first-party and not blocked by Safari ITP.

Without a custom loader domain, Safari treats the Meta cookies as third-party and wipes them within seven days. A full breakdown of how iOS 17 ATT breaks browser-only pixel attribution covers the deeper mechanics.

Read the cookies in your server handler and pass them on the event:

async function buildUserData(req: Request, order: ShopifyOrder) {
  const cookieHeader = req.headers.get("cookie") ?? "";
  const fbp = parseCookie(cookieHeader, "_fbp");
  const fbc = parseCookie(cookieHeader, "_fbc");

  return {
    em: hashEmail(order.email ?? order.customer?.email),
    ph: hashPhone(order.phone ?? order.billing_address?.phone),
    external_id: hashExternalId(order.customer?.id),
    fbp: fbp ?? undefined,
    fbc: fbc ?? undefined,
    client_ip_address: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
    client_user_agent: req.headers.get("user-agent") ?? undefined,
  };
}

Do not hash fbp or fbc. They are already opaque IDs. Hashing them turns them into garbage that will never match.

Step 5: Add billing address fields

The last 0.4 points of the score come from address fields. City (ct), state (st), zip (zp), and country (country). Each hashed individually with the same normalization rules as email.

function hashAddressPart(raw: string | null | undefined): string | undefined {
  if (!raw) return undefined;
  const normalized = raw
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9]/g, ""); // remove spaces, punctuation, accents
  if (!normalized) return undefined;
  return createHash("sha256").update(normalized).digest("hex");
}

function buildAddressFields(order: ShopifyOrder) {
  const addr = order.billing_address ?? order.shipping_address;
  if (!addr) return {};
  return {
    ct: hashAddressPart(addr.city),
    st: hashAddressPart(addr.province_code), // two-letter state code
    zp: hashAddressPart(addr.zip?.split("-")[0]), // first 5 digits for US
    country: hashAddressPart(addr.country_code), // two-letter ISO
  };
}

Two normalization traps. State must be the two-letter code (ca, not california), zip must be the five-digit format in the US (no ZIP+4), country must be the two-letter ISO code (us, not usa or united states). Meta's hash on file uses these specific forms, and anything else misses.

The score that actually moves Meta's ad algorithm is match quality, not match rate.

Normalize before hashing, always

The normalization habit is a cross-cutting move, not a step. Every field I walked above gets trimmed, lowercased, and cleaned before hashing. Not after. Not sometimes.

Pull this into a single utility so your handlers are all calling the same normalization path. The single most common regression I see is a new engineer adding a field to the payload, hashing it without the normalization step, and silently dropping the match quality on that event type by 0.3 over a weekend.

export const normalize = {
  email: (s: string) => s.trim().toLowerCase(),
  phoneE164: toE164,
  id: (s: string | number) => String(s).trim(),
  addressPart: (s: string) => s.trim().toLowerCase().replace(/[^a-z0-9]/g, ""),
};

export function hashField(kind: keyof typeof normalize, raw: unknown): string | undefined {
  if (raw == null || raw === "") return undefined;
  const normalized = (normalize[kind] as (v: unknown) => string | undefined)(raw as string);
  if (!normalized) return undefined;
  return createHash("sha256").update(normalized).digest("hex");
}

Lock the rule in your code review process: any PR that adds a field to the CAPI payload must route through this utility. The match quality score on the rebuild held steady at 9.1 for six months specifically because this was the only entry point. I covered the broader pattern of locking event contracts in the payload mismatch postmortem.

What you will not fix with PII alone

The PII lift gets you from 6 to 9. The last point (9 to 10) is almost never worth chasing, and for most DTC stores it is architecturally out of reach.

A 10 requires that a very high percentage of your events include PII that exactly matches Meta's hash-on-file. That in turn requires customers who are logged into Facebook or Instagram with the same email, phone, and address they used at checkout, on the same device. The gap from 9 to 10 lives almost entirely in cross-device and account-mismatch edge cases that you cannot engineer around.

More importantly, the marginal ad-algorithm benefit from 9 to 10 is smaller than the gap from 6 to 9. I would not spend a week hunting the last 0.1. I would spend the week making sure every event type (ViewContent, AddToCart, InitiateCheckout, Purchase) hits 9 independently. The per-event-type score is where most rebuilds plateau. A full implementation pattern is in the field guide to Meta CAPI.

If your Purchase is at 9 but ViewContent is at 6, the ad algorithm cannot reliably use your view events as a signal, and your top-of-funnel optimization suffers. Walk the same five fields above on every event type.

Common mistakes

Four things break for most stores pushing a CAPI match quality rebuild.

Skipping external_id. It is the second-highest-impact field after email, and the least-used. Half the CAPI implementations I audit do not pass it at all.

Hashing un-normalized strings. Mixed-case email, phone without country code, state as full name. The hash is technically correct and Meta technically received it, but it matches nothing. The field shows as "present" but not "matched," which is worse than sending no field at all.

Forgetting fbp and fbc on non-purchase events. The cookies exist on every page view, but a lot of implementations only forward them on Purchase. The result is ViewContent and AddToCart stuck at a lower match quality, and those are the events that drive top-of-funnel optimization.

Letting PII fall off during testing. A test mode that swaps real order data for dummy strings can cause a match quality cliff when the test code path accidentally ships to production. Gate test data behind a clearly named env var, never behind an HTTP header or URL param that can survive a deploy.

What to try next

Once your match quality is above 9 on Purchase, do two things.

Audit every other event type individually. The per-event score is what the algorithm actually uses for that event's optimization. A 9 on Purchase means nothing for ViewContent optimization if ViewContent is at 6.

Run a diagnostic scan for drift. The score will regress over time as new engineers touch the handlers, third-party apps add events, or a webhook handler gets forked. The CAPI Leak Report covers 14 checks including per-field normalization and per-event match quality drift. It is the scan I wrote to catch this class of regression on DTC stores that already shipped a rebuild once.

FAQ

Is match quality the same as event match rate?

No. Match rate is the percentage of events Meta could tie to any user at all. Match quality is the confidence of those matches on a 1 to 10 scale. You can have a 90% match rate and a 6 match quality if your PII is normalized poorly.

How often does Meta update the match quality score?

Roughly every 7 days, aggregated across the events received in that window. Changes you ship today will not show up in Events Manager for a week. Do not iterate hour-by-hour; run your changes for a full week before judging.

What match quality score do I need to see an actual ad performance lift?

The jump from 6 to 8 produces a meaningful lift in reported conversions and in the algorithm's optimization signal. Above 8.5 the marginal returns flatten. Most DTC stores should target 8.5 to 9 on every event type, not 9 to 10 on one event type.

Do I need to send PII on every single event, or just Purchase?

Every event that matters to your funnel. The match quality score is reported per event name, and the ad algorithm uses the per-event score when optimizing for that event. ViewContent, AddToCart, InitiateCheckout, and Purchase all need PII. Lower-intent events (page views) are lower priority.

Will hashing PII myself cause issues with Meta's verification?

No, Meta expects you to hash before sending. The CAPI contract explicitly requires SHA-256 hashed values for most PII fields. Sending raw values will cause Meta to reject the event. Sending hashed values with wrong normalization will cause Meta to silently drop the field from matching.

Does iOS 17 ATT affect match quality?

Not directly, but it affects match rate, which reduces the total volume of events eligible to be scored. ATT kills the browser pixel on Safari, so server-side PII enrichment becomes the only signal. See the iOS 17 ATT deep dive for the mechanics.

Sources and specifics

  • Score progression (6 to 9.1) comes from the Q2 2024 Shopify DTC rebuild documented in the tracking gap case study.
  • Normalization rules (lowercase email, E.164 phone, two-letter ISO country, hashed five-digit zip) are Meta's published CAPI contract as of April 2026.
  • Per-event match quality scoring has been live in Events Manager since 2023; the 7-day aggregation window is documented in Meta's Events Manager help center.
  • External_id is the least-used field across roughly a dozen CAPI audits I have run on DTC stores since 2024.
  • The utility pattern for single-entrypoint hashing is the same discipline I used in the CAPI debug postmortem to prevent schema drift across handlers.

// related

DTC Stack Audit

If this resonated, the audit covers your tracking layer end-to-end. Server-side CAPI, dedup logic, and attribution gaps - all mapped to your stack.

>See what is covered