focosys.io
← Writing
Essay · Tracking Infrastructure

Low Meta CAPI match quality is inflating your CPA. Here's the fix order

A CAPI match quality score under 6.0 optimizes worse and costs more per conversion. Work through hashing, external_id, and IP/user-agent mismatches in this order to fix it.

Low Meta CAPI match quality is inflating your CPA. Here's the fix order
Key takeaways
  • EMQ below 6.0 almost always traces to three things: unhashed or malformed parameters, missing high-value identifiers (em, ph, external_id), and IP/user-agent mismatches between browser and server events.
  • Check Events Manager, then Diagnostics before touching code. Meta tells you exactly which parameters are weak, but in my experience it's rarely the first place people look.
  • Hashing errors fail silently. Meta accepts a malformed SHA-256 hash without an error and just scores the match lower, so a 64-character string that looks right can still be wrong.
  • Adding external_id (your own persistent user or customer ID) has been one of the higher-leverage fixes I've seen for accounts stuck between 4 and 6, though results vary by account.

A client came to me with a CAPI implementation that looked complete on paper. Purchase events, add-to-cart, lead forms, all firing server-side with a real event_id for deduplication. Meta Events Manager showed the events arriving. No errors in the logs.

Event Match Quality: 4.3.

Their CPA on Meta had crept up for three straight months and nobody connected it to match quality, because match quality doesn't show up on the campaign dashboard. It's buried in Events Manager, and in my experience it's the kind of thing people check once during setup and then forget about.

Below 6.0, Meta can't confidently tie your server events to a real Facebook or Instagram user. It still counts the conversion. It just optimizes worse, because the algorithm is guessing at attribution instead of matching it. You end up paying for something close to the audience quality of an account with no CAPI at all, except now you've also built and are maintaining a server-side pipeline for it.

This post assumes you already know your score is low. If you haven't confirmed that yet or want the full breakdown of what each parameter contributes and how the score is calculated, start with how to validate your Meta CAPI event match quality. That post covers diagnosis. This one covers what to do once you already know the number is bad and need to find the specific broken field.

Start in Events Manager, not your code

Before you touch a single line of the payload, go to Events Manager, then Data Sources, then your pixel, then Diagnostics. Meta will list every event with a per-parameter breakdown: which fields are present, and a strength indicator (weak, good, great) for each.

This is the part people skip. They assume the fix is "send more data" and start adding fields blind. Diagnostics tells you which fields are already weak and why, so you're not guessing.

Common findings on a 4-6 score:

  • em (hashed email) and ph (hashed phone) present but flagged weak, which often means a hashing problem, not a missing-data problem
  • client_ip_address and client_user_agent missing entirely on server events
  • No fbp or fbc cookie values passed through from the browser event
  • No external_id

Fix based on what Diagnostics actually shows you. Don't fix the field that's easiest to fix first.

Where Diagnostics lives

Events Manager, select your data source, then the Diagnostics tab (not Overview, not Test Events). It only populates once you have live traffic, so if you just deployed CAPI, give it a day before checking.

Hashing failures don't throw errors

Hashing failures don't throw errors

This is the trap. Meta's API doesn't reject a malformed hash. It accepts the string, treats it as a weak or unmatched signal, and quietly lowers your score. You get a 200 OK response and a bad match, every time.

The usual causes:

// Wrong: not normalized before hashing
const email = "John.Smith@Gmail.com";
const hash = sha256(email); // capital letters, no trim

// Right: lowercase, trimmed, then hashed
const email = "John.Smith@Gmail.com".trim().toLowerCase();
const hash = sha256(email);

Meta's documented guidance calls for lowercase, whitespace-trimmed values before hashing for email and name fields. Phone numbers need to be normalized to E.164 format (country code, digits only, no spaces or dashes) before hashing. For example, using a placeholder number:

// Wrong (placeholder example number, not a real line)
const phone = "(415) 555-0182";
const hash = sha256(phone);

// Right
const phone = "+14155550182";
const hash = sha256(phone);

A hash of the wrong string is still a valid-looking 64-character hex string. It'll pass any "is this a hash" check you write. It just won't match anything on Meta's side, because Meta is hashing its own normalized version of the same user data and comparing hash-to-hash.

Pull 5-10 recent CAPI payloads from your server logs or sGTM preview mode and manually re-hash the raw values using Meta's documented normalization rules. If your hash doesn't match what you'd get from the correct process, that's your problem, and it's usually in exactly one field, not all of them.

The parameters that actually move the score

In the implementations I've worked on, em, ph, and external_id tend to carry more weight than fn/ln (first/last name), which in turn seem to matter more than location data or browser signals like fbp/fbc. This is an observed pattern across accounts, not published Meta documentation, so treat it as a diagnostic order to work through rather than a confirmed weighting formula.

If you're stuck between 4 and 6, check these in order:

1. external_id. This is your own internal customer or user ID, hashed the same way as other PII fields. Most implementations skip it because it feels redundant if you're already sending email. It isn't. Meta appears to treat it as an independent matching signal, and in accounts I've worked on it's frequently one of the fields that moves the score meaningfully upward. If you have a logged-in user system or even a persistent cookie-based user ID, send it.

"user_data": {
  "em": "a1b2c3...",
  "external_id": "d4e5f6...",
  "client_ip_address": "203.0.113.4",
  "client_user_agent": "Mozilla/5.0..."
}

2. client_ip_address and client_user_agent. These come from the server request, not the browser event, and I see them missing constantly in custom CAPI implementations built without a framework. If you're proxying through your own backend before sending to Meta, make sure you're capturing the end user's IP and UA, not your server's.

3. fbp and fbc cookies. These are set by the Meta Pixel in the browser (_fbp cookie always, _fbc only if the user arrived via a Meta ad click). If your server-side event fires without reading these cookies first, you're throwing away a strong, ready-made signal. Pull them from the request cookies and pass them through unmodified, don't hash them.

4. Name and location fields. Lower weight individually, but stacking fn, ln, ct (city), st (state), zp (zip), and country helps when you don't have a phone number to send. Each one hashed separately, not concatenated.

Test with Meta's own tool before redeploying

Events Manager, then Test Events lets you fire a single event and see the match quality breakdown immediately, without waiting for aggregate data to accumulate. Use this to validate a fix before you push it to production traffic, otherwise you're waiting 24-48 hours to find out if a change worked.

Browser and server events need to agree

A subtler failure: your Pixel event and CAPI event both fire, both have decent parameters individually, but they describe two different realities. The Pixel captures the browser's IP and user agent at the moment of the click. If your CAPI event fires later, from a different server process, with a different (or missing) IP and UA, Meta has trouble reconciling them even with a matching event_id.

This shows up most in delayed-conversion setups: someone adds to cart, leaves, and a webhook fires the CAPI purchase event 20 minutes later from your order processing system. By then you may not have access to the original request's IP and user agent unless you explicitly stored them at the time of the browser event and passed them through your pipeline.

Store client_ip_address and client_user_agent at the point of first contact (page load, form start) if the actual conversion event is going to fire later server-side. Don't try to reconstruct them from the order record, they won't be there.

If you're also seeing duplicate conversions alongside a low match score, that's a separate problem worth ruling out. See Facebook CAPI deduplication for how to check whether the Pixel and CAPI are double-counting the same events.

What "gateway" setups get wrong

If you're running CAPI through a tag management gateway (sGTM with the Meta CAPI tag template, Stape, or a similar setup) rather than a custom server integration, the failure mode is usually a field mapping gap, not a hashing bug.

The template does the hashing for you, correctly, if you give it raw values. But it can only send what you map into it. I've seen sGTM containers where external_id and fbp/fbc were available in the incoming event data but never mapped into the Meta CAPI tag's User Data fields, because the person who built the tag copied a starter template and only filled in email and phone.

Open your sGTM container, go to the Meta CAPI tag, and check every field under User Data against what's actually available in your dataLayer or client event. If a field exists upstream and isn't mapped, that's free score you're leaving on the table. If you're debugging the sGTM container itself and not sure where the data is dropping, debugging server-side GTM across the full event chain covers the layered approach for tracing it from browser to destination. If you're still deciding whether to migrate to a gateway setup in the first place, migrating to server-side GTM walks through what changes in the CAPI pipeline before you commit to it.

It's also worth checking whether your CAPI setup has other compounding issues beyond match quality. The 3 Meta CAPI mistakes costing you conversions covers setup errors that show up alongside low EMQ, not just as a symptom of it.

What to check this week

Pull your EMQ from Events Manager, then Diagnostics right now, not the campaign-level dashboard. If it's under 6.0, work the list in this order: verify hashing normalization on 5-10 real payloads, add external_id if it's missing, confirm client_ip_address/client_user_agent are the end user's values and not your server's, and confirm fbp/fbc cookies are being read and passed through unhashed.

Re-check the score after 48 hours of live traffic, not immediately. Meta's Diagnostics tab aggregates over a rolling window, so a fix deployed this morning won't show a clean number by this afternoon.

focosys — Measurement systems writingMore writing →