How We Pushed Our Meta Pixel Event Match Quality Scores Up (And How You Can Too)
How We Pushed Our Meta Pixel Event Match Quality Scores Up (And How You Can Too)
If you're running Meta ads, Event Match Quality (EMQ) is one of the highest-leverage numbers you're probably not looking at. It sits quietly in Events Manager, scored out of 10 per event, and it directly affects how well Meta can attribute conversions to your ads, which in turn affects how well the algorithm optimises delivery and how much you pay per result.
We spent a serious chunk of engineering time raising our EMQ scores across every event we send. This post covers what actually moved the needle for us: not the theory, the practice. No magic and no grey-hat tricks, just sending Meta more of the signal you already legitimately have.
What EMQ actually measures
When you send Meta an event (a page view, a lead, a purchase), Meta tries to match it to a real person on their platform. The more identifiers you attach (email, phone, name, location, click IDs, browser IDs), and the better their quality, the higher the match rate. EMQ is Meta's score for how matchable your events are.
Low EMQ means conversions silently fail to attribute. Your campaigns look worse than they are, the algorithm optimises on partial data, and your cost per acquisition drifts up. Raising EMQ is one of the few ad-performance levers that's entirely an engineering problem.
1. Run the browser pixel and the Conversions API together, with deduplication
The single biggest structural change: don't choose between the browser pixel and the server-side Conversions API (CAPI). Run both, for the same events.
The browser pixel is easy but fragile: ad blockers, Safari's tracking prevention, and flaky connections all eat events. The server side never misses, and it can attach identifiers the browser doesn't have. Meta explicitly rewards redundancy here: when both arrive, it keeps the union of the match keys.
The catch is deduplication. Both versions of an event must share the same event_id and event name, or Meta counts your conversions twice:
For each conversion, generate one event ID client-side, pass it to
fbq('track', ...)as theeventIDoption, and post it to your own API, which forwards it on the CAPI event.Don't use the user's ID as the event ID; it has to be unique per occurrence, not per person, or repeat events collapse into one.
For server-only events triggered by webhooks (more on those below), use a naturally idempotent ID like the invoice ID, so webhook retries can't double-fire.
One subtle gotcha: if you mirror PageView server-side, the browser and server versions can race on the first page load. Make sure whichever fires first generates the shared ID and the other consumes it; otherwise your highest-volume event never dedupes.
2. Send every match key you legitimately have
Meta's user_data object accepts far more than most integrations bother to send. Ours populates, wherever available: email, phone, first name, last name, external ID, fbp and fbc (the browser and click ID cookies), client IP address, user agent, and country/city/state/zip.
Three things we learned doing this:
Use the official server SDK and let it do the hashing. Meta requires PII to be SHA-256 hashed after normalisation (lowercased emails, E.164-ish phones, and so on). Hand-rolling this is a classic source of silent mismatches: a hash of
John@Example.comand a hash ofjohn@example.comare different people as far as matching is concerned. Thefacebook-nodejs-business-sdknormalises before hashing; use it.Pass advanced matching on pixel init too. The browser pixel accepts
em,ph,fn,ln, andexternal_idat init time for logged-in users. Lowercase the values first.Use a consistent
external_ideverywhere. We use the same stable user ID asexternal_idon page views and conversions alike, so an anonymous session that later converts stitches into one person on Meta's side instead of two half-matched ones.
A note on sourcing: pull each field from the most authoritative place you have, with fallbacks. Our purchase events, for example, prefer the identity on the user's account but fall back to what the payment processor has on file (Stripe stores customer email, phone, and billing address). Billing addresses turned out to be a great source of deterministic zip and state, much better than IP-based geolocation, which we deliberately don't use for zip/state because it's too noisy to help matching.
3. Handle click IDs properly: the fbc formatting trap
When someone clicks a Meta ad, the landing URL carries an fbclid parameter. Meta's pixel writes it into a _fbc cookie in a specific format: fb.<subdomain index>.<timestamp>.<click id>.
Two failure modes bit us here:
The cookie doesn't always get written. If your landing page loads the pixel late or the user bounces through a redirect, the
fbclidis in the URL but no_fbccookie exists. We capturefbclidfrom the URL as early as possible and write the cookie ourselves if the pixel hasn't.Sending a raw
fbclidasfbcgets flagged as malformed. If you persist the click ID (in local storage, user metadata, or your analytics tool) and later send it server-side, you must wrap it in the fullfb.1.<timestamp>.<clickid>format. At one point a large share of our purchase events were being flagged malformed for exactly this reason; a small normalisation function fixed it overnight.
Also persist the click ID beyond the first session. A user who clicks an ad today and buys next week should still carry that click ID onto the conversion event. We store it at first touch and attach it to the user's profile once they sign up.
4. The hard part: events the browser never sees
Our highest-value event, Purchase, doesn't come from a browser at all. It fires from a payment webhook when an invoice is paid, including subscription renewals. There's no pixel, no cookies, no request headers. If you do nothing, these events arrive nearly naked and drag your Purchase EMQ down every billing cycle.
The fix is to snapshot browser identity while you have it, so you can replay it later:
Mirror the
fbpcookie into your user store. On page views for logged-in users, we copy the_fbpbrowser cookie into the user's profile (delta-checked, so we're not writing on every load). When the webhook fires weeks later, the server event carries the samefbpthe browser had. Before this,fbpcoverage on purchases was effectively zero.Snapshot the user agent at checkout. A renewal webhook has no user agent. We save the real one when the user starts checkout and reuse it on subsequent purchase events.
Get
event_timeright. Use the actual payment timestamp from your payment provider, not when your webhook happened to process. Recovered invoices can pay days after they're created, and Meta only accepts events within a 7-day window, so the wrong timestamp silently drops them.
The broader lesson: for any server-triggered event, EMQ is determined by what you persisted earlier, not what you can gather at send time. Audit each match key on your server-only events and ask "where would this come from?" Every gap is a persistence job.
5. Fix identity collection upstream
Some match keys you can't engineer your way into; you have to collect them. Phone number is the big one: it's one of the strongest match keys Meta accepts, and most SaaS signups never ask for it.
We made phone collection part of signup. That decision alone changes the ceiling on what your Purchase and Lead EMQ can reach. One thing to be aware of if you sell subscriptions: your existing customers keep firing purchase events with their old, incomplete identity on every renewal. Improving signup only helps new users. If you want the score to move on a reasonable timescale, you also need to backfill identity for existing users (fallback data sources, in-app prompts, enrichment at send time), not just wait for the cohort to turn over.
6. Stop polluting your own dataset
EMQ is an average, so junk events drag it down as effectively as good events lift it:
Only send from production. Dev and staging page loads are anonymous, identity-free events landing in the same dataset. Gate the pixel and the server mirror on environment.
Exclude crawler-heavy pages. Public docs and similar pages attract bot and anonymous traffic that will never match. We skip the pixel there entirely.
But keep coverage where paid traffic lands. Don't blanket-exclude auth pages. If your ads land people on a signup page, that page needs the pixel, or you lose the very click IDs you're paying for.
What we'd tell you to do first
If your EMQ scores are low and you want the shortest path up:
Add the Conversions API alongside your pixel, with proper
event_iddeduplication.Send email, phone, name,
external_id,fbp,fbc, IP, and user agent on every server event, via the official SDK.Capture and correctly format
fbc, and persist click IDs across sessions.For webhook-driven events, snapshot
fbpand user agent while the user is in the browser.Collect phone at signup, and backfill identity for existing users.
Gate out non-production and crawler traffic.
None of this is a growth hack. It's plumbing: making sure the signal you already own actually reaches Meta intact. But it's plumbing that shows up directly in attribution, delivery, and cost per result, and almost nobody does all of it.