Back to Blog
marketing· 12 min read

Meta CAPI + iOS Attribution — The Deduplication Setup That Actually Works

Why most Meta CAPI implementations double-count, why iOS conversions still go missing even with CAPI on, and the exact event_id and event_time pattern that fixes both — with sample code.

The problem nobody admits

You install Meta CAPI. Events Manager turns green. Event match quality goes from 5.4 to 7.8. Everyone congratulates everyone.

Two weeks later, your CFO points at the dashboard: Meta is reporting 38% more purchases than Shopify is. ROAS looks great until you compare it to actual revenue, at which point it looks made up.

This is the most common Meta CAPI failure mode and it's not subtle. It's that your browser Pixel and your server CAPI are firing the same event twice, and Meta isn't deduplicating them because the implementation is missing the one thing that makes dedup work.

The second-most-common failure: iOS conversions still missing even with CAPI installed, because the team confused "CAPI on" with "CAPI configured correctly for iOS."

Both are fixable in an afternoon. Here's the exact pattern.

How Meta dedup actually works

Meta's deduplication isn't fuzzy matching on timestamps or values. It's an exact match on two fields:

  • event_name — must be identical (Purchase, AddToCart, etc)
  • event_id — must be the same UUID on both browser and server within a 48-hour window

If both match, Meta keeps one event. Which one? The one that arrives first, with the second discarded. That's it.

Things that break dedup:

  • Generating event_id separately on browser and server (you'll get two different UUIDs)
  • Generating it from the order ID without including it on the browser side
  • Sending CAPI from a backend that fires after the order is committed (browser fires first → CAPI fires 30 seconds later → still in the 48hr window → discarded as a dupe → only browser is counted, no server-side iOS recovery)
  • Sending different event_name strings (Pixel sends Purchase, your CAPI tag sends purchase)

The pattern that works

The event_id must be:

  1. Generated once in the browser at the moment the event happens
  2. Passed to the Pixel via the eventID parameter
  3. Passed to your CAPI server alongside the rest of the payload
  4. Sent server-to-Meta with the same value in event_id
  5. Optional but smart: persisted on your order record so backend webhooks can replay if the browser request was blocked

Browser-side, in your purchase template:

// Generate ONCE, share between Pixel and CAPI
const eventId = crypto.randomUUID();

// 1. Fire to the Meta Pixel with eventID
fbq('track', 'Purchase', {
  value: orderTotal,
  currency: 'USD',
  contents: lineItems.map(i => ({ id: i.sku, quantity: i.qty, item_price: i.price })),
  content_type: 'product',
}, { eventID: eventId });

// 2. Fire to your CAPI endpoint with the SAME eventId
await fetch('https://t.yourdomain.com/capi', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    event_name: 'Purchase',
    event_id: eventId,
    event_time: Math.floor(Date.now() / 1000),
    value: orderTotal,
    currency: 'USD',
    email_sha256: await sha256(customerEmail),
    fbc: getCookie('_fbc'),
    fbp: getCookie('_fbp'),
    page_location: location.href,
  }),
});

// 3. Persist on the order so a webhook can replay if the fetch was blocked
await fetch('/api/orders/' + orderId + '/event-id', {
  method: 'POST',
  body: JSON.stringify({ event_id: eventId }),
});

The crypto.randomUUID() is browser-native, no library, and supported everywhere except ancient Edge.

The iOS-specific trap

CAPI was sold as the iOS fix. It's not — by itself.

Here's what actually happens on an iOS Safari purchase:

  1. User clicks Meta ad → lands on your site
  2. Meta Pixel sets _fbc and _fbp cookies
  3. iOS Safari ITP caps these cookies at 7 days if they're set client-side
  4. User comes back 8 days later, completes purchase
  5. Browser Pixel fires, but _fbc is gone → low match quality
  6. Server fires CAPI, but it's also reading _fbc from the same browser request → also gone
  7. Meta can't reliably attribute the conversion → it falls into modeled conversions or vanishes

CAPI alone doesn't fix this. What fixes it is first-party cookies, and you get them by routing your Pixel and CAPI through a subdomain you own.

The setup:

  • t.yourdomain.com — proxied through Cloudflare or hosted on a Worker
  • Pixel fbq calls go through your subdomain (set the data domain in Pixel settings or use a server-side tag)
  • The _fbc and _fbp cookies are now set by t.yourdomain.com on a server response, not by Pixel JS
  • iOS Safari sees them as first-party server-set cookies → no 7-day cap

This is the missing 30% that "we installed CAPI" teams don't have. CAPI sends the event server-side, but if _fbc isn't there, Meta can't connect the click to the conversion.

The event_time issue

Most production CAPI implementations send event_time: Math.floor(Date.now() / 1000) from the server at the moment of the API call. That timestamp can be 1–60 seconds after the actual user action — your queue, your retry logic, your webhook lag.

Meta's matching window is forgiving on time, but two cases break:

  1. Late webhook replays. Stripe webhook fires 4 minutes after checkout. Server event_time is now 4 minutes after the browser Pixel fired. If your dedup is working, the browser event wins. If your dedup ID is wrong, you've got two events 4 minutes apart that Meta can't reconcile.
  2. Backfill jobs. Someone runs a script to send last week's missed events to CAPI. Default code uses "now" as event_time. Meta marks them as suspicious and your match quality drops.

The fix: capture event_time in the browser when the user actually clicks the buy button, pass it through every layer, and use that exact unix timestamp in CAPI no matter when the request finally lands.

const eventId = crypto.randomUUID();
const eventTime = Math.floor(Date.now() / 1000);

// Send both fields everywhere

Keep them paired. They're the audit trail for what happened.

The _fbc reconstruction trick

If a user clicks an ad with ?fbclid=XXX in the URL but disabled cookies, you can still reconstruct _fbc server-side:

function reconstructFbc(fbclid: string | null, eventTime: number): string | null {
  if (!fbclid) return null;
  return `fb.1.${eventTime * 1000}.${fbclid}`;
}

The format is exactly fb.{subdomain_index}.{timestamp_ms}.{fbclid}. Subdomain index is 1 for .yourdomain.com. Timestamp is when the click happened, not when the conversion happened. Pull fbclid from the landing URL on first visit and persist it in localStorage or a server session if you want to do this right.

This recovers conversions where the user blocked third-party cookies entirely. We see it pull back another 4–8% of attributable purchases on stores with heavy mobile traffic.

Hashed user data — what actually moves the needle

Meta's match quality scoring weights different fields wildly differently. From observed data on dozens of accounts:

  • em (hashed email) — the single biggest match quality lift. Goes from 5.x → 7.x just by adding it
  • ph (hashed phone) — strong for SaaS, weaker for D2C unless you collect it at checkout
  • fbc — high signal, but only present if the user clicked a Meta ad
  • fbp — present for nearly everyone, low individual signal but counts toward the score
  • client_ip_address + client_user_agent — table stakes, must be present, won't lift score by themselves
  • external_id — your own user ID. Lifts score noticeably for repeat customers because Meta builds a graph from it across visits

Always send em even on ViewContent and AddToCart events if you have the user's email (logged-in browse, recovered abandoned cart, etc). Most teams only send it on Purchase. You're leaving match-quality lift on the table.

The hash function:

async function sha256(value) {
  if (!value) return undefined;
  const data = new TextEncoder().encode(value.trim().toLowerCase());
  const hash = await crypto.subtle.digest('SHA-256', data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, '0')).join('');
}

Lowercase + trim before hashing. Meta's docs say this. Some implementations skip the trim and lose match on every email with a trailing space (more common than you'd think).

Verifying the setup is actually working

Three places to check, in order:

1. Meta Events Manager → Test Events. Set a test_event_code in your CAPI requests during staging. Open the Test Events tab. Make a purchase. You should see one event appear, marked as deduplicated against the browser. If you see two events, dedup is broken — usually mismatched event_id.

2. Events Manager → Diagnostics. Look for "Mismatched data" warnings. The most common one is "event_id provided but not matched" — meaning your server sent an event_id that has no browser counterpart. That points to either the browser fetch being blocked (good — that's the iOS recovery you want) or the browser code generating a different ID than the server (bug).

3. Compare reported revenue to your platform. Pull last 30 days of Meta-attributed purchases from Events Manager. Compare to Shopify or your billing system over the same window with utm_source=facebook or fb. They should be within 5% on a stable account. If Meta is showing 30%+ higher, dedup is broken.

What we typically deliver on a Meta CAPI engagement

For reference — this is roughly the scope when we ship this for a client:

  • CAPI server (Worker or sGTM) deployed to a first-party subdomain
  • Browser-side event_id and event_time generation, persisted on the order
  • All standard events covered: PageView, ViewContent, AddToCart, InitiateCheckout, Purchase, Lead, CompleteRegistration
  • Hashed email and phone wired in everywhere we have them
  • _fbc reconstruction from fbclid for cookie-disabled users
  • Webhook replay path for orders where the browser fetch was blocked
  • Test event suite verifying dedup before we cut over
  • Documentation so you don't need us to add the next event

It's a 2-week engagement on a typical Shopify or B2B SaaS site. Cost recovers itself the first month from the additional attributed revenue Meta now sees.


If your CAPI is on but your numbers don't match Shopify, or your iOS Meta ROAS looks worse since iOS 14.5 and never recovered — send us your domain and we'll run a free 15-minute diagnostic of the dedup and _fbc setup. If it's broken, you'll know within the first day.

Need help with this in your business?

Our Digital Marketing service starts with a free audit.