What This Tutorial Covers
By the end, every Shopify order will report to Meta twice on purpose, once from the browser and once from your server, both carrying the same event ID so Meta keeps one. The server report will carry thirteen of Meta's fifteen web match keys. Refunds will fire a correction instead of sitting there teaching the algorithm to find more people who want their money back.
You will need:
- A Shopify store you control (this setup does not work when you are sending traffic to a merchant's store you have no admin access to)
- A Meta dataset with a Conversions API access token
- Somewhere to run a webhook endpoint: a serverless function, a small server, or a tracker that accepts Shopify order webhooks
- Admin access to Settings > Customer events and to your app's webhook subscriptions
One date to check before you touch anything. Shopify Plus stores lost checkout.liquid, Additional Scripts and script tags on the Thank you and Order status pages on August 28, 2025. Non-Plus stores lose them on August 26, 2026, and Shopify auto-upgrades the store and deletes the legacy code when it happens. If your Meta Purchase event is still a snippet pasted into the Additional Scripts box, it has days left. That is the migration this tutorial doubles as.
Why Shopify Breaks the Pattern
Meta's Conversions API accepts fifteen user identifiers on a web event: email, phone, first name, last name, date of birth, gender, city, state, zip, country, external ID, client IP, client user agent, fbc and fbp. On a typical CPA offer you send four or five of them and hope Event Match Quality lands somewhere respectable.
A Shopify order hands you nine of the fifteen directly out of the order payload. Four more you capture yourself on the landing click. The two you never get are date of birth and gender, because Shopify does not ask for either at checkout. Thirteen of fifteen, without asking the buyer for one extra field.
That is the whole reason to build this properly. You own the collection point, so the identity is yours to send.
Both reports carry the order-derived event ID, so Meta collapses the pair instead of counting the sale twice.
Step 1: Stamp the Click Onto the Cart
Before anything fires, the click identifier has to survive from the ad to the order record. Cookies alone will not carry it, because the order payload your server reads later has no idea what was in the browser.
Shopify gives you a durable slot for this: cart attributes. Whatever you write there travels through checkout and lands on the order as note_attributes. On your landing page, read the click parameters from the URL and write them to the cart:
// Landing page (your theme), before the buyer reaches checkout
const params = new URLSearchParams(location.search);
const clickId = params.get('cv_click_id') || params.get('fbclid');
if (clickId) {
fetch('/cart/update.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attributes: { cv_click_id: clickId } })
});
localStorage.setItem('cv_click_id', clickId);
}
At the same moment, POST the click to your own endpoint with the visitor's IP, user agent, _fbp, _fbc and landing URL, keyed by that same cv_click_id. This click record is what turns a bare order webhook into a full identity payload later. Skip it and you are down to nine match keys instead of thirteen.
Failure point: writing the attribute after the buyer has already reached checkout. Cart attributes lock in at order creation. Write them on the landing hit, not on an exit intent.
Step 2: Subscribe to checkout_completed
Go to Settings > Customer events > Add custom pixel. This replaces the old Additional Scripts field. Custom pixels and app web pixel extensions are the two supported ways to run your own JavaScript on the post-purchase pages; the extension runs in a stricter sandbox, and everything below assumes a custom pixel.
analytics.subscribe('checkout_completed', async (event) => {
const checkout = event.data.checkout;
const orderId = checkout.order?.id ?? checkout.token;
const eventId = `shopify-${orderId}`; // the shared dedup key
const [clickId, fbp, fbc] = await Promise.all([
browser.localStorage.getItem('cv_click_id'),
browser.cookie.get('_fbp'),
browser.cookie.get('_fbc')
]);
fetch('https://track.example.com/shopify/browser-purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({
event_id: eventId,
click_id: clickId,
fbp, fbc,
value: checkout.totalPrice?.amount,
currency: checkout.currencyCode
})
});
});
Two limits of the sandbox to hold in your head. Custom pixels run in what Shopify calls a lax sandbox, an iframe with allow-scripts and allow-forms, and it cannot reach the top frame. window.href returns the sandbox URL, not your store URL. Anything that scrapes or writes the DOM is out. The browser global is your way back to the top frame for cookies and storage, and every one of its methods returns a Promise, which is why the code above awaits them.
The second limit matters more. checkout_completed fires once per checkout, normally on the Thank you page. With post-purchase upsells it fires on the first upsell offer page instead and is not fired again later. And if the page where it should fire never loads, the event never fires at all. That is the exact hole the server half closes.
One thing I could not confirm from Shopify's documentation: whether a third-party tag such as fbq, loaded inside the lax sandbox, writes its _fbp cookie to your store's top-frame origin or to the sandbox origin. The documented, supported path for top-frame cookies from a custom pixel is browser.cookie. If you want a browser-side Meta Purchase, either fire fbq from this pixel and verify the cookie behaviour yourself in Events Manager, or skip the browser event entirely and go server-only. Meta is explicit that advertisers who do not send the same event twice do not need deduplication at all.
Step 3: The Server Half
Subscribe your app to orders/paid and point it at your endpoint. Verify the HMAC against the raw body before you parse a single field, then build the CAPI event with the same ID string the pixel used.
import crypto from 'node:crypto';
const sha = (v) => crypto.createHash('sha256').update(v).digest('hex');
const norm = (v) => (v ?? '').toString().trim().toLowerCase();
const h = (v) => (norm(v) ? sha(norm(v)) : undefined);
const hTight = (v) => h((v ?? '').toString().replace(/[\s-]/g, ''));
export async function ordersPaid(rawBody, headers) {
const digest = crypto
.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
.update(rawBody)
.digest('base64');
const sent = Buffer.from(headers['x-shopify-hmac-sha256'] ?? '', 'utf8');
const mine = Buffer.from(digest, 'utf8');
if (sent.length !== mine.length || !crypto.timingSafeEqual(sent, mine)) return 401;
const order = JSON.parse(rawBody);
const attrs = Object.fromEntries(
(order.note_attributes ?? []).map((a) => [a.name, a.value])
);
const click = await lookupClick(attrs.cv_click_id); // from Step 1
const a = order.billing_address ?? order.shipping_address ?? {};
await fetch(`https://graph.facebook.com/v23.0/${PIXEL_ID}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: process.env.META_CAPI_TOKEN,
data: [{
event_name: 'Purchase',
event_time: Math.floor(new Date(order.created_at).getTime() / 1000),
event_id: `shopify-${order.id}`, // matches the pixel eventID
action_source: 'website',
event_source_url: click?.landing_url,
user_data: {
em: [h(order.email)],
ph: [sha((order.phone ?? a.phone ?? '').replace(/\D/g, ''))],
fn: [h(a.first_name)],
ln: [h(a.last_name)],
ct: [hTight(a.city)],
st: [h(a.province_code)],
zp: [hTight(a.zip)],
country: [h(a.country_code)],
external_id: [sha(String(order.customer?.id ?? order.id))],
client_ip_address: click?.ip,
client_user_agent: click?.user_agent,
fbc: click?.fbc,
fbp: click?.fbp
},
custom_data: { currency: order.currency, value: Number(order.total_price) }
}]
})
});
return 200;
}
One caveat on client_ip_address and client_user_agent. Meta reads both as a description of the browser that generated the event, and a webhook fires hours or days after the landing hit, so the values on your Step 1 click record are stale and may even belong to a different network by then. Prefer whatever the browser reported at checkout_completed, pass it to your endpoint alongside the event ID, and store it against the order. Fall back to the landing record only when the browser path never fired at all, and treat that as a weaker signal rather than an equivalent one.
Return the 2xx fast and do the CAPI call out of band if your endpoint is slow. Shopify retries a failed delivery up to eight times over four hours, and X-Shopify-Webhook-Id is the header you deduplicate retries on so one order does not fire three Purchase events.
Failure point: using orders/create instead of orders/paid. Order creation and payment capture are not the same moment, and a Purchase event fired before money moved is a Purchase event you will be correcting later.
Step 4: Get the Hashing Right
Meta rejects nothing when you hash badly. It just quietly fails to match, your Event Match Quality sits low, and you never find out why. Normalize first, then SHA-256, then hex.
Email: trim and lowercase. Phone: digits only, leading zeros stripped, country code included even when every buyer is domestic. City: lowercase with no spaces or punctuation, so New York becomes newyork. State: the two-character code, lowercase. Zip: lowercase, no spaces or dashes, first five digits for US. Country: the lowercase two-letter ISO code. Never hash client_ip_address, client_user_agent, fbc or fbp; those four go through raw.
Nine keys come free with the order, four come from the click you logged yourself, and two are simply not part of a Shopify checkout.
Step 5: Send the Refund Back
Subscribe to refunds/create. When it fires, look up the original order, rebuild the same user_data block, and send a correction with a negative value and a different event ID:
{
"event_name": "Purchase",
"event_time": 1757937600,
"event_id": "shopify-1042-refund",
"action_source": "system_generated",
"user_data": { "...": "identical to the original event" },
"custom_data": { "currency": "USD", "value": -84.50 }
}
The event ID has to differ from the original, or Meta treats the correction as a duplicate of the sale and drops it. Meta does not publish a first-class refund event type for web conversions, so a negative-value Purchase is the pattern in common use; confirm it behaves as expected on your own account before you build reporting on top of it.
Why bother. An unreconciled refund is worse than no signal. You told Meta that person was a buyer, Meta went and found more people like them, and the money came back out three weeks later. Do that at scale and you have paid to train a model on your worst cohort.
Step 6: Verify It, Three Places
Shopify first. Place a test order and check that the custom pixel actually ran. Shopify's help docs cover testing custom pixels; what you are looking for is the checkout_completed subscription firing and your endpoint receiving the POST. If nothing arrives, the usual cause is a promise you did not await.
Events Manager, Test Events. Add test_event_code to your server payload and watch the Purchase land. The specific thing to look at is the user_data list on the received event: count the populated keys. If you see four, your click lookup returned nothing and the cart attribute never made it through.
Events Manager, event details. Two numbers here. Event Match Quality on Purchase, which needs a day or two of real volume to stabilise, and the deduplication state. You want the browser and server Purchase pairing up, not stacking. If your reported conversions are roughly double your Shopify order count, the IDs are not matching.
Common Mistakes
1. Sending empty strings for db and ge. An empty hash is not a missing field, it is a wrong field. Omit the key.
2. Regenerating the event ID on each side. Derive it from the order ID on both, and let the order ID be the only source. shopify-${order.id} on the server, the same string in the pixel.
3. Ignoring the 48-hour window. Meta only deduplicates events received within 48 hours of the first one carrying that event ID. A webhook stuck in a retry queue overnight is fine. One replayed from a backfill script a week later doubles the count.
4. Trusting the browser event alone because it looked fine in testing. You tested on a device that loaded the Thank you page. Post-purchase upsells move the event to the upsell page, and a buyer who closes the tab at the payment spinner never fires it at all.
5. Leaving the old Additional Scripts snippet as a backup. Until August 26, 2026 it still runs on non-Plus stores, which means a second, un-deduplicated Purchase alongside your new one. Migrate the event, confirm parity for a few days, then remove it.
What Good Looks Like
Your Shopify order count and your Meta reported Purchases track each other within a few percent. Event Match Quality on Purchase sits in the top band because thirteen keys are landing instead of five. Refunds show up as corrections within minutes of the customer clicking the button. And none of it depends on a Thank you page loading in the same browser session as the ad click.
That last part is what you bought by owning the checkout. Use it.
If you would rather not maintain a webhook endpoint, an event ID scheme and a hashing pipeline by hand, a tracking layer that stamps both events and handles refund corrections removes the seam where these setups drift. See how one layer keeps the browser and server events in sync. The rules above hold either way: one ID on both sides, thirteen keys hashed correctly, and the refund goes back.
FAQ
Can I still use Additional Scripts for my Shopify Meta pixel?
Not for much longer, and not at all if you are on Plus. Plus stores lost Additional Scripts, script tags and checkout.liquid on the Thank you and Order status pages on August 28, 2025. Non-Plus stores lose them on August 26, 2026, at which point Shopify auto-upgrades the store and removes the code. The replacement is a custom pixel under Settings > Customer events.
What does the checkout_completed event actually give me?
The checkout object: line items, totals, currency, discount applications, transactions, the order ID and customer ID, plus email, phone, billing and shipping addresses and the cart attributes you set earlier. The contact and address fields sit behind Shopify's protected customer data scopes, so an app pixel needs those scopes approved before it sees them.
Do I need both the custom pixel and the order webhook?
You need the webhook. The pixel is optional redundancy. checkout_completed does not fire at all if the page it belongs to fails to load, and with post-purchase upsells it fires on the upsell page rather than the Thank you page. The webhook fires from Shopify's side regardless. If you run only the server event, you can skip deduplication entirely.
Why is my Event Match Quality still low with a Shopify store?
Almost always the click record. The nine order-derived keys land easily; fbc, fbp, IP and user agent come from the click you logged on the landing page, and if the cart attribute did not carry your click ID through checkout, the lookup returns nothing and those four go missing. Check note_attributes on a real order before blaming the hashing.
How do I send a Shopify refund to Meta?
Subscribe to the refunds/create webhook, look up the original order, and send a Purchase event with the same user_data, a negative value, and a different event_id from the original sale. Reusing the original ID makes Meta treat the correction as a duplicate and discard it.
