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>
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)
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
});
Two things trip people up here:
-
eventIDgoes 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. - 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()}`);
}
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.stringifyalready dropsundefined. -
Use
API_VERSIONfrom 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 } })
});
}
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);
}
Also verify the webhook HMAC on the raw body before doing anything, as in any Shopify webhook handler.
Verify it instead of trusting it
-
Test Events tab in Events Manager: add the
test_event_codeto your server payload and place an order. You should see both the browser and server events, and the server one marked as deduplicated. - Check the event ID shown on both events. If they differ even by a prefix, there's your bug.
- 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.
-
Look at event match quality for the server events. Low scores usually mean missing
fbp,fbcor email. - 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)