Meta CAPI Setup 2026: Server-Side Events, Deduplication, and the EMQ Score
The pixel plus conversions api stack that meta's ranking models actually train on: which parameters move event match quality, how deduplication really works, the server code for a purchase from a payment webhook, what changed in 2025 and 2026, and the checks an agent runs weekly.
most capi guides are from 2022. they tell you to install the pixel, add the conversions api "for ios," and check that events show up. in 2026 the conversions api is not a backup. it is the signal meta's retrieval and ranking models train on, and meta reports exactly how good that signal is, per event, in the dataset quality api. this guide is the setup that gets the score up and keeps it there.
what the pixel plus capi stack actually is
meta's signal stack has two paths into the same dataset.
| path | mechanism | what it captures | what it misses |
|---|---|---|---|
| browser | meta pixel (fbevents.js) | real-time events, the _fbp and _fbc cookies, page context, advanced matching fields | anything blocked by ad blockers, safari, firefox, ios app tracking transparency, consent banners; server-only events such as refunds and renewals |
| server | conversions api (https post) | the same events plus server truth: payment webhooks, crm status changes, refunds, subscriptions | browser context unless you forward fbp, fbc, ip, and user agent from the request |
meta's own guidance: "use the conversions api in addition to the meta pixel, and share the same events using both tools" (meta for developers, conversions api best practices).
here is what most guides get wrong: they treat capi as an ios workaround. it is the primary signal. the pixel is the redundant path that keeps the real-time loop alive and carries the cookies. the quality of what you send from the server, measured as event match quality, is what andromeda and the ranking models behind it (see the algorithms behind meta ads) use to find more people like your converters.
the fundamental shift: from "events arrive" to "events match"
before: success meant events showing up in events manager. the pixel did the matching; the server just needed to send something.
after: meta scores every event source on four things and exposes them programmatically. as of may 28, 2025 the dataset quality api reports event match quality with per-identifier diagnostics, event coverage ("the 7-day average percent of pixel events that are covered by the conversions api"), deduplication coverage per key, data freshness (real time, hourly, and so on), and additional conversions reported (meta for developers, dataset quality api). meta rates event match quality as poor, ok, good, or great and says to "aim for an event match quality rating of good or great" (meta business help center, about event match quality).
in plain english: the question changed from "is capi installed" to "what is the emq on purchase, what share of purchases arrive from both sources with a shared id, and how fresh are they." those three numbers are the setup.
what this broke in existing accounts
1. pixel-only accounts fell behind the ranking models
andromeda and gem train on matched conversions. a pixel-only account on safari-heavy traffic sends a fraction of its purchases with a fraction of the identifiers. the account's lookalikes, its advantage+ optimization, and its reported roas were all built on that fraction.
2. capi installed, email only, emq stuck at ok
the single most common 2023-era setup: a partner integration sending hashed email and nothing else. meta's best practices name email, ip address, name, and phone as the high-quality parameters and require client_user_agent and event_source_url on every website event. email alone is one of four.
3. duplicate purchases doubling roas
meta deduplicates a pixel event and a server event only when they share event_name and either event_id or the combination of external_id and fbp (meta for developers, handling duplicate pixel and conversions api events). two integrations that each think they own the purchase event, with no shared id, count every order twice.
4. fbc missing because fbclid was never persisted
_fbc is created only when the pixel sees ?fbclid= on landing. a redirect or consent reload that drops the query string, or a checkout on a different subdomain, loses it. meta's guidance is to send fbc "with every event you send to the conversions api" (meta for developers, fbp and fbc parameters). accounts that never captured it are attributing ad clicks on ip and user agent.
5. apple stripped the click id
safari 26, released september 15, 2025, made advanced fingerprinting protection the default, and link tracking protection removes known click identifiers such as fbclid from links opened from mail, messages, and private browsing. the server has to carry hashed identifiers because on apple traffic the click id often does not survive.
6. hashing that meta could not match
Jane@Example.com hashed as typed does not match jane@example.com. a phone hashed with a plus sign or without the country code does not match. USA does not match us. the diagnostics tab shows "we received unhashed data" for the worst cases and silence for the rest.
7. stale setup guides
the aggregated event measurement tab and the eight-event ranking were removed from events manager in mid-2025, with meta now aggregating eligible web events automatically (jon loomer, meta announces big changes to website conversion campaigns). guides that tell you to rank eight events are describing a screen that no longer exists.
the data
- meta's stated benefit is qualitative: using the conversions api alongside the pixel creates a more reliable connection that helps the delivery system decrease cost per action, and additional customer information parameters increase matched events (meta business help center, about conversions api). meta does not publish a universal percentage.
- meta's best practices require
action_sourceon all events andevent_source_urlandclient_user_agenton website events, recommendexternal_idandevent_idon all events, and name email, ip address, name, and phone as the high-quality identifiers. events should be shared "in real time or in batches close to real time." (meta for developers, conversions api best practices) - the dataset quality api, since may 28, 2025, reports per-event emq with identifier coverage, event coverage, deduplication coverage per key, data freshness, and additional conversions reported. offline events support is in beta. (meta for developers, dataset quality api)
_fbcand_fbpcookies carry a 90-day expiration.fbccan be constructed from thefbclidquery parameter even with no pixel running. (meta for developers, fbp and fbc parameters)- haus, 640 meta incrementality tests since 2024: 7-day click in-platform attribution under-reports meta's incremental effect by 15% on average (haus, the meta report). better matching narrows that gap; it does not close it.
- meta, january 2026: advertisers using incremental attribution saw a 24% increase in conversions versus the standard attribution model, self-reported (meta newsroom). incremental attribution is trained on matched conversions, so it inherits your emq.
- marketing api v25.0 shipped february 18, 2026 (meta for developers blog). use it in the endpoint path.
what changed in 2026 specifically
february 2026: marketing api v25.0. update the version in your capi endpoint. older versions keep working on meta's deprecation schedule, but new parameters land in the current version first.
january 2026: meta reported the 24% incremental attribution figure and doubled the gpus training its ranking model. both raise the value of a clean server signal: incremental attribution and the ranking models are trained on what you send.
september 2025: safari 26. click ids are unreliable on apple traffic. hashed identifiers on the server are the durable match keys.
mid-2025: aggregated event measurement lost its tab and its eight-event cap. nothing to configure; everything eligible aggregates.
may 2025: the dataset quality api added coverage, deduplication, freshness, and emq diagnostics. every check in the agent block at the end reads from this api.
unchanged: sha-256 hashing rules, the event_id plus event_name deduplication key, the 90-day cookie lifetime, and the requirement that eu traffic without consent sends nothing.
what actually works now
1. credentials and access
dataset id. meta has renamed pixel to dataset in events manager because one dataset now ingests web, app, offline, and crm events. same id, new label. events manager, select the data source, settings, copy the dataset id.
access token. use a system user token in production.
| method | when | lifetime |
|---|---|---|
| system user token | production. tied to a business manager system user, survives staff turnover. | configurable, never-expire supported |
| user token from the conversions api "generate access token" button | local testing only | 60 days |
create it at business settings, system users, add, assign the ad account and dataset with manage pixel permission, generate a token scoped to ads_management and business_management, store it in a secret manager.
domain verification. add the facebook-domain-verification txt record at the apex domain. the meta tag and html upload methods work but do not survive theme changes and cdn moves. verification is what lets your business claim the domain and control who can run ads to it.
2. standard events, spelled exactly
meta optimizes against named standard events. a misspelled event is accepted as a custom event and gets minimal optimization weight. purchase is not Purchase.
| stage | event | required | recommended | fire when |
|---|---|---|---|---|
| product page | ViewContent | none | content_ids, content_type, value, currency | pdp view |
| add to cart | AddToCart | contents for advantage+ catalog | value, currency, content_ids | add click |
| checkout | InitiateCheckout | none | value, currency, num_items | checkout start |
| payment | AddPaymentInfo | none | value, currency | billing captured |
| purchase | Purchase | currency, value, and contents or content_ids for advantage+ | order_id, num_items | order confirmed, from the server on the payment webhook |
| signup | CompleteRegistration | none | value, currency, status | account created |
| lead | Lead | none | value, currency, lead_event_source | form submitted |
| subscription | Subscribe | none | value, currency, predicted_ltv | paid subscription begins |
| trial | StartTrial | none | value, currency, predicted_ltv | trial begins |
for advantage+ catalog ads, contents is the structured form: [{ id: "SKU-123", quantity: 1, item_price: 29.99 }].
3. customer information parameters, normalized then hashed
this is where emq lives. every personal field is sha-256 hashed after normalization. hash the wrong string and the match silently fails.
| param | field | hash | normalize first |
|---|---|---|---|
em | yes | trim, lowercase | |
ph | phone | yes | digits only, with country code, no plus sign |
fn, ln | first, last name | yes | trim, lowercase, strip punctuation |
ct | city | yes | lowercase, no spaces or punctuation |
st | state | yes | lowercase two-letter code (us) or lowercase name |
zp | postal code | yes | first five digits (us), lowercase |
country | country | yes | lowercase iso 3166-1 alpha-2 |
db | date of birth | yes | YYYYMMDD |
ge | gender | yes | m or f |
external_id | your customer id | yes | trim, lowercase |
client_ip_address | ip | no | raw |
client_user_agent | user agent | no | raw |
fbc | click cookie | no | raw cookie value |
fbp | browser cookie | no | raw cookie value |
subscription_id, lead_id, fb_login_id | platform ids | no | raw |
priority, from meta's best practices plus what moves emq in accounts:
em. meta's graph is email-anchored.fbc. the strongest ad-click signal. capturefbclidon landing, persist 90 days, send on every event.fbp. present whenever the pixel ran in that browser. never omit.ph. second-best identifier, especially mobile-first audiences.external_id. ties sessions together when other identifiers rotate. meta recommends it on every event.client_ip_addressandclient_user_agent. required on website events, raw.- name, city, state, zip, country, birthday, gender. cheap once you have any of them.
USA instead of us; double-hashing when the platform (shopify, tag manager templates) already hashed. the test events tool shows hashed fields under "user information." blank means you sent nothing meta could use.cookie formats. capi expects both cookies verbatim.
_fbp = fb.<subdomainIndex>.<creationTimeMs>.<random>
e.g. fb.1.1714435200000.987654321
_fbc = fb.<subdomainIndex>.<creationTimeMs>.<fbclid>
e.g. fb.1.1714435200000.IwAR0Xy7QzExample...
subdomainIndex is 0 for .com, 1 for example.com, 2 for www.example.com. if the pixel is not running, build _fbc from the url yourself:
// construct fbc when the pixel is unavailable
const params = new URLSearchParams(window.location.search);
const fbclid = params.get('fbclid');
if (fbclid) {
const fbc = `fb.1.${Date.now()}.${fbclid}`;
document.cookie = `_fbc=${fbc}; path=/; max-age=${60 * 60 * 24 * 90}; SameSite=Lax; Secure`;
}
4. advanced matching in the browser
automatic advanced matching (events manager, settings) scrapes form fields and hashes them in the browser. manual advanced matching passes known values into fbq('init', ...) after login or form submit, and gets a higher match rate.
<script>
fbq('init', 'YOUR_DATASET_ID', {
em: 'jane@example.com', // the pixel hashes for you
ph: '12125551234', // digits with country code, no plus
fn: 'jane',
ln: 'doe',
ct: 'sanfrancisco',
st: 'ca',
zp: '94103',
country: 'us',
external_id: 'cust_4f9a'
});
fbq('track', 'PageView');
</script>
do not pre-hash values passed to fbq('init'); the pixel hashes them. if you run manual advanced matching, turn automatic advanced matching off, or the two produce conflicting parameter sets.
5. read event match quality the way meta reports it
meta publishes the labels. the numeric bands below are what we see in accounts.
| score | meta rating | typical cause | effect |
|---|---|---|---|
| 0 to 3 | poor | ip and user agent only, or fbp alone | the models are guessing; cpa inflates |
| 4 to 5 | ok | email only, or inconsistent hashing | borderline; the most common inherited state |
| 6 to 7 | good | several keys, some normalization gaps | optimization works; reported cpa mostly right |
| 8 | great | email, phone, fbp, fbc, ip, user agent, external_id, all clean | the floor for advantage+ shopping |
| 9 to 10 | great | plus consistent external_id and a second stable key | maximum matching confidence |
read it per event in events manager (dataset, overview, event match quality) or programmatically from the dataset quality api, which also tells you which identifier is missing on what share of events.
6. deduplication that actually works
the same event from both paths collapses to one when event_name matches (case-sensitive) and event_id matches (identical string), or when external_id plus fbp match. generate the id once, on the server at render time, and use it in both calls.
// server generates the id at page render and injects it
const eventId = crypto.randomUUID();
// browser
fbq('track', 'Purchase', {
value: 29.99,
currency: 'USD',
content_ids: ['SKU-123'],
contents: [{ id: 'SKU-123', quantity: 1 }]
}, {
eventID: eventId // the dedup key
});
// server, later, from the payment webhook
await sendCapiEvent({
event_name: 'Purchase',
event_id: eventId, // same value
// ...
});
| symptom | cause | fix |
|---|---|---|
| reported purchases about 2x real orders | event_id not shared, or one source missing it | test events tool: both sources must show the same event id |
| one source dedups, the other does not | purchase versus Purchase | standardize spelling everywhere |
| dedup works for some orders only | server fires before the page renders, no shared id | generate the id server-side at render and inject into the html |
| two purchases from two integrations | shopify channel plus a second capi app both own purchase | pick one owner per event; disable the other's purchase |
7. the purchase from the payment webhook
direct pixel plus direct capi is the recommended stack for anything with a backend. the endpoint, current as of marketing api v25.0:
POST https://graph.facebook.com/v25.0/<DATASET_ID>/events?access_token=<TOKEN>
Content-Type: application/json
// server/lib/meta-capi.js
import crypto from 'node:crypto';
const META_DATASET_ID = process.env.META_DATASET_ID;
const META_ACCESS_TOKEN = process.env.META_CAPI_ACCESS_TOKEN;
const API_VERSION = 'v25.0';
function sha256(value) {
if (!value) return undefined;
return crypto.createHash('sha256').update(String(value).trim().toLowerCase()).digest('hex');
}
function normalizePhone(phone) {
if (!phone) return undefined;
return String(phone).replace(/\D/g, ''); // caller supplies the country code
}
export async function sendMetaEvent({
eventName,
eventId,
eventTime, // unix seconds
eventSourceUrl, // page where the event happened
actionSource, // 'website' | 'email' | 'app' | 'phone_call' | 'chat' | 'physical_store' | 'system_generated' | 'business_messaging' | 'other'
user,
customData,
testEventCode // dev only
}) {
const payload = {
data: [
{
event_name: eventName,
event_time: eventTime || Math.floor(Date.now() / 1000),
event_id: eventId,
event_source_url: eventSourceUrl,
action_source: actionSource || 'website',
user_data: {
em: user.email ? [sha256(user.email)] : undefined,
ph: user.phone ? [sha256(normalizePhone(user.phone))] : undefined,
fn: user.firstName ? [sha256(user.firstName)] : undefined,
ln: user.lastName ? [sha256(user.lastName)] : undefined,
ct: user.city ? [sha256(user.city.replace(/\s/g, ''))] : undefined,
st: user.state ? [sha256(user.state)] : undefined,
zp: user.zip ? [sha256(user.zip.slice(0, 5))] : undefined,
country: user.country ? [sha256(user.country)] : undefined,
external_id: user.id ? [sha256(user.id)] : undefined,
client_ip_address: user.ip, // raw
client_user_agent: user.userAgent, // raw
fbc: user.fbc, // raw cookie value
fbp: user.fbp // raw cookie value
},
custom_data: customData
}
],
...(testEventCode ? { test_event_code: testEventCode } : {})
};
const url = `https://graph.facebook.com/${API_VERSION}/${META_DATASET_ID}/events?access_token=${META_ACCESS_TOKEN}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`Meta CAPI ${res.status}: ${await res.text()}`);
return res.json();
}
wire it to the payment webhook so purchase comes from the source of truth:
// app/api/webhooks/stripe/route.js
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { sendMetaEvent } from '@/server/lib/meta-capi';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const sig = req.headers.get('stripe-signature');
const buf = await req.arrayBuffer();
const event = stripe.webhooks.constructEvent(Buffer.from(buf), sig, process.env.STRIPE_WEBHOOK_SECRET);
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object;
const meta = pi.metadata || {};
await sendMetaEvent({
eventName: 'Purchase',
eventId: meta.meta_event_id, // generated at checkout, written to the payment intent
eventTime: Math.floor(pi.created),
eventSourceUrl: meta.checkout_url,
actionSource: 'website',
user: {
email: pi.receipt_email,
phone: meta.phone,
id: pi.customer,
ip: meta.client_ip,
userAgent: meta.client_ua,
fbc: meta.fbc,
fbp: meta.fbp
},
customData: {
currency: pi.currency.toUpperCase(),
value: pi.amount_received / 100, // stripe amounts are in cents
order_id: pi.id,
contents: JSON.parse(meta.line_items || '[]')
}
});
}
return NextResponse.json({ received: true });
}
2999 instead of 29.99 and reported revenue is 100x too high, and value-based bidding trains on it.the server needs the browser context. on checkout start, read the cookies and send them with the event id:
// browser, on checkout init
const fbp = document.cookie.match(/_fbp=([^;]+)/)?.[1];
const fbc = document.cookie.match(/_fbc=([^;]+)/)?.[1];
await fetch('/api/checkout/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cartId, eventId: crypto.randomUUID(), fbp, fbc })
});
the server reads the first ip from x-forwarded-for and the user-agent header, then writes fbp, fbc, ip, user agent, and eventId into the payment intent metadata so the webhook has them.
8. the other integration paths
conversions api gateway. meta's hosted intermediary that turns pixel events into server events. self-hosted container on aws, gcp, or azure, or a managed provider. good for marketing sites and agencies with no backend access. it sees only what the browser exposed, so no payment or crm enrichment, and emq lands below a direct integration. not the right choice for ecommerce with refunds or subscriptions.
server-side tag manager. meta pixel template in the web container, meta's conversions api tag in the server container, event_id mapped from the data layer into both. the server container forwards ip and user agent; you populate fbp and fbc from cookies with variables.
shopify. the native facebook and instagram channel installs pixel and capi, deduplicates on the order number, and sends purchase, add to cart, view content, initiate checkout, and search. it does not reliably forward fbc and fbp through checkout extensibility. verify in test events. the most common shopify duplication is the native channel plus a second capi app both sending purchase.
woocommerce. the official facebook for woocommerce plugin handles event_id deduplication. same fbc and fbp caveat.
9. consent
limited data use (ccpa and cpra). meta counts the conversion but does not use it for targeting, custom audiences, or lookalikes.
// browser
fbq('dataProcessingOptions', ['LDU'], 0, 0); // 0,0 lets meta geo-detect
// server payload
data_processing_options: ['LDU'],
data_processing_options_country: 0,
data_processing_options_state: 0
eu, eea, and uk. limited data use is not gdpr consent. without consent, do not initialize the pixel, do not write _fbp, do not send capi. gate both paths behind the consent platform. meta has no equivalent of google's modeled conversions for non-consented users.
10. verify it every week
- test events tool. events manager, dataset, test events. pass the test code as
test_event_codein the server payload. both sources should appear within 30 seconds with the same event id. strip the code in production or real events become invisible. - diagnostics tab. missing required parameters, unhashed data, dedup mismatches, domain verification problems, emq drops.
- event coverage. most purchases should arrive from both sources. capi-only purchases mean the browser is blocked, which is expected. pixel-only purchases mean the server path is missing orders.
- dataset quality api. read emq, identifier coverage, dedup coverage, and freshness per event, and alert on drops.
| issue | likely cause | fix |
|---|---|---|
| events accepted, not optimized | event name typo | audit casing on both paths |
| emq stuck at 4 to 5 | email un-normalized, or email only | lowercase and trim; add phone, fbc, external_id |
| purchases about 2x orders | no shared event_id | inspect test events for matching ids |
| revenue 100x too high | cents sent as value | divide by 100 |
fbc always missing | fbclid not persisted | capture on landing into a 90-day first-party cookie |
| all capi requests 400 | wrong api version, or user_data values not arrays | validate against the server event parameters doc |
| test events appear, real ones do not | test_event_code left in production | gate behind an environment check |
what to stop doing immediately
- stop treating the conversions api as an ios backup. it is the primary signal.
- stop sending email only. add phone,
fbc,fbp, ip, user agent, andexternal_id. - stop hashing un-normalized values.
- stop running two integrations that both send purchase.
- stop reading "events received" as success. read emq, coverage, dedup, and freshness.
- stop following guides that mention the aggregated event measurement tab or ranking eight events.
- stop sending pixel or capi events for eu users without consent.
- stop hard-coding an api version from 2024.
the orbit agent instructions
{
"guide": "meta-attribution-setup",
"agent_tasks": {
"audit_emq_per_event": {
"check": "read event match quality and identifier coverage for purchase, lead, and the account's optimization event from the dataset quality api",
"flag": "any optimization event rated poor or ok, or coverage below 80 percent on email, fbp, or client_user_agent",
"recommend": "add the missing identifiers at the server, normalize before hashing, and re-check after 7 days"
},
"audit_deduplication": {
"check": "read deduplication coverage per key and compare 7-day purchase counts in events manager with orders in the store or payment processor",
"flag": "purchase event coverage with a shared event_id below 90 percent, or a purchase count more than 15 percent above orders",
"recommend": "generate one event_id server-side at render, use it in both paths, and disable any second integration sending purchase"
},
"audit_event_coverage_and_freshness": {
"check": "read event coverage and data freshness for purchase and the optimization event from the dataset quality api",
"flag": "purchase coverage below 90 percent, any share of purchases arriving pixel-only, or server freshness slower than hourly",
"recommend": "move purchase to the payment webhook and send in real time"
},
"audit_fbc_capture": {
"check": "measure the share of server purchase events carrying fbc and fbp over the last 7 days",
"flag": "fbc present on fewer than 50 percent of server events from paid traffic, or fbp below 80 percent",
"recommend": "persist fbclid into a 90-day first-party cookie on landing, forward both cookies to the server at checkout start, and send them on every event"
},
"audit_setup_hygiene": {
"check": "confirm the marketing api version in the capi endpoint, domain verification status, system user token expiry, event name spelling on both paths, and that test_event_code is absent from production payloads",
"flag": "an api version older than the current release minus two, an unverified apex domain, a token expiring within 30 days, any lowercase standard event name, or a test code in production",
"recommend": "update the version, verify the domain via dns, rotate the token, fix the spelling, and remove the test code"
},
"audit_consent_gating": {
"check": "detect whether pixel initialization and server events for eu, eea, and uk users are gated behind consent, and whether limited data use is set for california users",
"flag": "pixel or capi events sent for eu users without a consent signal, or no limited data use flag on california traffic",
"recommend": "gate both paths behind the consent platform and set data processing options for california"
}
}
}
the bottom line
the conversions api is the signal meta trains on. send purchase from the payment webhook, in real time, with email, phone, fbc, fbp, ip, user agent, and external_id, normalized before hashing, deduplicated on one shared event_id. then read emq, coverage, dedup, and freshness every week from the dataset quality api.
events showing up was the 2022 bar. events matching is the 2026 one.
built by orbit - the performance marketing intelligence layer. questions? jose@orbitllm.com