MetaFoundationalGuide

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.

Updated September 25, 202622 min read

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.

pathmechanismwhat it captureswhat it misses
browsermeta pixel (fbevents.js)real-time events, the _fbp and _fbc cookies, page context, advanced matching fieldsanything blocked by ad blockers, safari, firefox, ios app tracking transparency, consent banners; server-only events such as refunds and renewals
serverconversions api (https post)the same events plus server truth: payment webhooks, crm status changes, refunds, subscriptionsbrowser 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_source on all events and event_source_url and client_user_agent on website events, recommend external_id and event_id on 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)
  • _fbc and _fbp cookies carry a 90-day expiration. fbc can be constructed from the fbclid query 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.

methodwhenlifetime
system user tokenproduction. tied to a business manager system user, survives staff turnover.configurable, never-expire supported
user token from the conversions api "generate access token" buttonlocal testing only60 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.

stageeventrequiredrecommendedfire when
product pageViewContentnonecontent_ids, content_type, value, currencypdp view
add to cartAddToCartcontents for advantage+ catalogvalue, currency, content_idsadd click
checkoutInitiateCheckoutnonevalue, currency, num_itemscheckout start
paymentAddPaymentInfononevalue, currencybilling captured
purchasePurchasecurrency, value, and contents or content_ids for advantage+order_id, num_itemsorder confirmed, from the server on the payment webhook
signupCompleteRegistrationnonevalue, currency, statusaccount created
leadLeadnonevalue, currency, lead_event_sourceform submitted
subscriptionSubscribenonevalue, currency, predicted_ltvpaid subscription begins
trialStartTrialnonevalue, currency, predicted_ltvtrial 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.

paramfieldhashnormalize first
ememailyestrim, lowercase
phphoneyesdigits only, with country code, no plus sign
fn, lnfirst, last nameyestrim, lowercase, strip punctuation
ctcityyeslowercase, no spaces or punctuation
ststateyeslowercase two-letter code (us) or lowercase name
zppostal codeyesfirst five digits (us), lowercase
countrycountryyeslowercase iso 3166-1 alpha-2
dbdate of birthyesYYYYMMDD
gegenderyesm or f
external_idyour customer idyestrim, lowercase
client_ip_addressipnoraw
client_user_agentuser agentnoraw
fbcclick cookienoraw cookie value
fbpbrowser cookienoraw cookie value
subscription_id, lead_id, fb_login_idplatform idsnoraw

priority, from meta's best practices plus what moves emq in accounts:

  1. em. meta's graph is email-anchored.
  2. fbc. the strongest ad-click signal. capture fbclid on landing, persist 90 days, send on every event.
  3. fbp. present whenever the pixel ran in that browser. never omit.
  4. ph. second-best identifier, especially mobile-first audiences.
  5. external_id. ties sessions together when other identifiers rotate. meta recommends it on every event.
  6. client_ip_address and client_user_agent. required on website events, raw.
  7. name, city, state, zip, country, birthday, gender. cheap once you have any of them.
common hashing mistakes: hashing email without lowercasing; phone with a plus sign, spaces, or parentheses; 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.

scoremeta ratingtypical causeeffect
0 to 3poorip and user agent only, or fbp alonethe models are guessing; cpa inflates
4 to 5okemail only, or inconsistent hashingborderline; the most common inherited state
6 to 7goodseveral keys, some normalization gapsoptimization works; reported cpa mostly right
8greatemail, phone, fbp, fbc, ip, user agent, external_id, all cleanthe floor for advantage+ shopping
9 to 10greatplus consistent external_id and a second stable keymaximum 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
  // ...
});
symptomcausefix
reported purchases about 2x real ordersevent_id not shared, or one source missing ittest events tool: both sources must show the same event id
one source dedups, the other does notpurchase versus Purchasestandardize spelling everywhere
dedup works for some orders onlyserver fires before the page renders, no shared idgenerate the id server-side at render and inject into the html
two purchases from two integrationsshopify channel plus a second capi app both own purchasepick 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 });
}
stripe amounts are in the smallest currency unit. send 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.

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_code in 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.
issuelikely causefix
events accepted, not optimizedevent name typoaudit casing on both paths
emq stuck at 4 to 5email un-normalized, or email onlylowercase and trim; add phone, fbc, external_id
purchases about 2x ordersno shared event_idinspect test events for matching ids
revenue 100x too highcents sent as valuedivide by 100
fbc always missingfbclid not persistedcapture on landing into a 90-day first-party cookie
all capi requests 400wrong api version, or user_data values not arraysvalidate against the server event parameters doc
test events appear, real ones do nottest_event_code left in productiongate 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, and external_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