DEV Community

Cover image for Shopify + Meta CAPI: Deduplicating Pixel and Server Events
Anshul Sandal
Anshul Sandal

Posted on

Shopify + Meta CAPI: Deduplicating Pixel and Server Events

Here's a ticket that shows up on every store that adds the Conversions API
(CAPI): "Meta now reports almost double our purchases. Last month it matched Shopify."

Nothing is wrong with Meta. You sent each order twice, and Meta had no way to know they were the same order. This post covers how deduplication works, the fields that make it work, and a pattern for Shopify that survives retries, consent and ad blockers.

How Meta decides two events are one

Meta deduplicates a browser (Pixel) event and a server (CAPI) event when they share both:

  • the same event_name (e.g. Purchase), and
  • the same event_id

Matching happens within a limited time window (Meta documents it as 48 hours, but check the current docs). Miss either field and you get two purchases. A different case (purchase vs Purchase) counts as a different name, so use Meta's standard event names exactly.

The rule of thumb: generate the ID once, from something both sides already know, and use it on both sides. For purchases, the order is the natural source.

event_id = "purchase_" + <shopify order id>
Enter fullscreen mode Exit fullscreen mode

Don't use Date.now() or a random UUID unless you pass that same value to the server. A random ID generated separately on each side is the most common cause of double counting.

The two paths

Browser:  Pixel  → fbq('track','Purchase', data, { eventID })
Server:   Order webhook → POST /{PIXEL_ID}/events  (event_id = same value)
Enter fullscreen mode Exit fullscreen mode

Why run both? The Pixel carries browser context (cookies, fbp, fbc). The server path survives ad blockers and closed tabs. Together they give better coverage, but only if dedup works.

Browser side: sending the Pixel event with an ID

On Shopify, add this in Settings → Customer events → Add custom pixel. Custom pixels run in a sandbox, so you subscribe to Shopify's events instead of reading the page. Check the current Web Pixels docs for the exact event schema and cookie access.

// Load the Meta Pixel (standard snippet), then:
fbq('init', 'YOUR_PIXEL_ID');

analytics.subscribe('checkout_completed', (event) => {
  const c = event.data.checkout;
  const orderId = String(c.order?.id ?? c.token);
  const eventID = `purchase_${orderId}`;

  fbq('track', 'Purchase', {
    value: Number(c.totalPrice.amount),
    currency: c.currencyCode,
    content_type: 'product',
    content_ids: c.lineItems.map(li => li.variant?.sku || String(li.variant?.id)),
    num_items: c.lineItems.reduce((n, li) => n + li.quantity, 0)
  }, { eventID }); // eventID goes in the 4th argument
});
Enter fullscreen mode Exit fullscreen mode

Two things trip people up here:

  1. eventID goes in the options object (the fourth argument), not inside the parameters. Put it in the wrong place and the Pixel sends no ID at all.
  2. Make sure the Purchase doesn't also fire from another source (a theme snippet, the Facebook & Instagram channel, a leftover app). Dedup works between the browser and server, but it won't save you from two browser purchases with different IDs.

Server side: the order webhook

The server needs the same ID, plus whatever matching data you can safely provide.

import crypto from 'node:crypto';

const sha256 = (v) =>
  crypto.createHash('sha256').update(v.trim().toLowerCase()).digest('hex');

async function sendPurchaseToMeta(order, attrs) {
  const event = {
    event_name: 'Purchase',
    event_time: Math.floor(new Date(order.created_at).getTime() / 1000),
    event_id: `purchase_${order.id}`,          // SAME as the browser
    action_source: 'website',
    event_source_url: attrs.landing_url || undefined,
    user_data: {
      em: order.email ? [sha256(order.email)] : undefined,
      ph: order.phone ? [sha256(order.phone.replace(/\D/g, ''))] : undefined,
      fbp: attrs.fbp,                           // from the _fbp cookie
      fbc: attrs.fbc,                           // from the _fbc cookie / fbclid
      client_ip_address: attrs.ip,              // NOT hashed
      client_user_agent: attrs.user_agent       // NOT hashed
    },
    custom_data: {
      value: Number(order.total_price),
      currency: order.currency,
      content_type: 'product',
      content_ids: order.line_items.map(li => li.sku || String(li.variant_id)),
      num_items: order.line_items.reduce((n, li) => n + li.quantity, 0)
    }
  };

  const res = await fetch(
    `https://graph.facebook.com/${API_VERSION}/${PIXEL_ID}/events?access_token=${TOKEN}`,
    { method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ data: [event] }) }
  );
  if (!res.ok) throw new Error(`CAPI ${res.status}: ${await res.text()}`);
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Hash only what Meta says to hash (email, phone, names, and so on, normalized first). IP address and user agent are sent in the clear. Check Meta's current parameter docs, since requirements change.
  • Normalize before hashing: trim, lowercase, and for phone numbers strip non-digits and include the country code.
  • Remove empty fields instead of sending empty strings. JSON.stringify already drops undefined.
  • Use API_VERSION from config, not a hardcoded value. Meta retires old Graph API versions.
  • Keep the access token on the server and out of the repo.

Getting fbp and fbc to the server

The server doesn't see the browser's cookies when the order webhook fires, so capture them earlier and attach them to the cart, as with any tracking identifier.

// storefront: on page load
function getCookie(n) {
  return document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)'))?.[1];
}
const fbp = getCookie('_fbp');
let fbc = getCookie('_fbc');

// Build fbc from fbclid if the cookie isn't set yet
const fbclid = new URLSearchParams(location.search).get('fbclid');
if (!fbc && fbclid) fbc = `fb.1.${Date.now()}.${fbclid}`;

if (fbp || fbc) {
  fetch('/cart/update.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ attributes: { fbp, fbc, landing_url: location.href } })
  });
}
Enter fullscreen mode Exit fullscreen mode

Cart attributes arrive on the order as note_attributes. Format of fbc matters (fb.1.<timestamp>.<fbclid>), so follow Meta's current documentation rather than my sketch if they differ. Remember consent: don't send identifiers for visitors who declined advertising cookies.

Make the webhook handler idempotent

Shopify can deliver a webhook more than once. Without protection, you send the same CAPI event repeatedly. Meta will usually dedupe them via event_id, but you shouldn't depend on that.

const done = new Set(); // use Redis or a DB in production

async function handler(order) {
  const key = `purchase_${order.id}`;
  if (done.has(key)) return;
  await sendPurchaseToMeta(order, attrsFor(order));
  done.add(key);
}
Enter fullscreen mode Exit fullscreen mode

Also verify the webhook HMAC on the raw body before doing anything, as in any Shopify webhook handler.

Verify it instead of trusting it

  1. Test Events tab in Events Manager: add the test_event_code to your server payload and place an order. You should see both the browser and server events, and the server one marked as deduplicated.
  2. Check the event ID shown on both events. If they differ even by a prefix, there's your bug.
  3. Compare counts over a few days: Meta's purchase count vs Shopify orders. A stable gap is normal (attribution, consent, modeling). A sudden doubling means dedup broke.
  4. Look at event match quality for the server events. Low scores usually mean missing fbp, fbc or email.
  5. Check the Diagnostics tab for warnings about missing parameters or duplicate events.

Common failure modes

Symptom Likely cause
Purchases doubled Different event_id on each side, or two browser Purchase events
Server events show but aren't deduplicated Different event_name casing, or the ID is in the wrong place on the Pixel
Low match quality fbp/fbc not captured, or email/phone not normalized before hashing
Server events missing Webhook failing (check HMAC and 200 response), or a CAPI token error
Browser events missing for some buyers Ad blockers, consent, or buyers leaving before the page loads. This is why the server path exists

If you'd rather not maintain this

Everything above is for one platform. Add TikTok, Pinterest and GA4, and you're maintaining the same event ID scheme, identity capture and retry logic for each. Webgarh GTM Assistant is a Shopify app that handles client-side and server-side tracking through GTM, with event deduplication, Consent Mode v2 and tag diagnostics, if you'd rather not own all of that plumbing. Try it on a dev store and compare the events it sends against your own implementation.

Top comments (0)