We were losing 38% of our purchase signals to Meta. Not misattributing them — losing them entirely. Meta's pixel never saw them. Neither did our ad algorithms. We were optimizing Advantage+ campaigns against 62% of our actual customers.
This is how we fixed it without paying $150/mo for Google Cloud sGTM.
Why Pixels Die Before They Reach Meta
Standard ad pixels fire an outbound HTTP request from the visitor's browser:
user converts → fbq('track', 'Purchase') → https://connect.facebook.net/...
Ad blockers intercept that request at the network level. uBlock Origin, Brave, Firefox Enhanced Tracking Protection — all block connect.facebook.net by default. iOS Safari adds another layer: ITP (Intelligent Tracking Prevention) nukes client-side cookies after 7 days, breaking the identity chain entirely.
The result: any user who bought 8 days after their first ad click is invisible to your pixel, even if they convert. We measured this against our Shopify ground truth and found:
- GA4: 16% signal loss (202 reported vs 241 actual orders)
- Meta: 38% signal loss (149 reported vs 241 actual orders)
The Fix: Intercept Before the Network Layer
The key insight: ad pixels attach to window.fbq, window.gtag, window.ttq, etc. These are just JavaScript functions. You can wrap them before the outbound request fires.
const wrapped = new Set();
const queue = [];
function wrapPixel(name) {
const original = window[name];
if (!original || wrapped.has(name)) return;
window[name] = function (...args) {
// ✅ Captured here — before any network request
// No ad blocker can intercept a JS function call
queue.push({
platform: name,
args: JSON.parse(JSON.stringify(args)), // deep copy
timestamp: Date.now(),
url: window.location.href,
});
flushQueue();
// Let the original call proceed (may get blocked — doesn't matter now)
return original.apply(this, args);
};
wrapped.add(name);
}
// Pixels often initialize asynchronously — poll until they exist
const TARGETS = ['fbq', 'gtag', 'ttq', 'pintrk', 'snaptr', 'rdt', 'twq'];
const huntingLoop = setInterval(() => {
TARGETS.forEach(name => {
if (window[name]) wrapPixel(name);
});
}, 500);
setTimeout(() => clearInterval(huntingLoop), 10_000);
The queue gets flushed to your own first-party endpoint — something like https://events.yourdomain.com/collect. Ad blockers can't block your own domain without breaking your site entirely.
async function flushQueue() {
if (queue.length === 0) return;
const batch = queue.splice(0, queue.length);
await fetch('https://events.yourdomain.com/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: batch }),
keepalive: true, // survives page unload
});
}
Solving the ITP Cookie Problem
Wrapping gets you the event payload. But you still need to link it to the user's prior sessions — the TikTok click they made 10 days ago before Safari wiped everything.
The difference comes down to how you set the cookie:
// ❌ Client-side — ITP deletes this after 7 days (or sooner on cross-site loads)
document.cookie = `zy_uid=${userId}; max-age=31536000`;
// ✅ Server-side — Set-Cookie header survives ITP indefinitely (first-party)
// In your edge function / server route:
res.setHeader('Set-Cookie', [
`zy_uid=${userId}; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax; Path=/`
]);
When a user lands from any ad click, your edge function:
- Reads click ID params from the URL (
fbclid,gclid,ttclid,sclid, etc.) - Sets a durable first-party server-side cookie mapping
zy_uid→ click attribution data - On conversion 30 days later, that cookie is still there — readable server-side
This extends your attribution window from Safari's 7-day maximum to however long you want.
Server-Side Forwarding to Meta CAPI
Once events arrive at your endpoint, forward them to Meta's Conversions API. Unlike the browser pixel, CAPI calls come from your server — zero ad blocker risk.
// Node.js / Vercel Edge example
import crypto from 'crypto';
async function forwardToMetaCAPI(event) {
const { platform, args, userId, ip, userAgent } = event;
// Only forward Meta pixel events
if (platform !== 'fbq') return;
const [action, eventName, data] = args;
if (action !== 'track') return;
const payload = {
data: [{
event_name: eventName, // 'Purchase', 'AddToCart', etc.
event_time: Math.floor(Date.now() / 1000),
action_source: 'website',
event_source_url: event.url,
user_data: {
client_ip_address: ip,
client_user_agent: userAgent,
// Hash PII fields — required by Meta
em: data.email ? sha256(data.email.toLowerCase().trim()) : undefined,
ph: data.phone ? sha256(normalizePhone(data.phone)) : undefined,
fbc: event.cookies?.fbc, // Facebook click ID cookie
fbp: event.cookies?.fbp, // Facebook browser ID cookie
},
custom_data: {
currency: data.currency || 'USD',
value: data.value,
content_ids: data.content_ids,
content_type: data.content_type,
},
event_id: `${userId}-${eventName}-${Date.now()}`, // for deduplication
}],
test_event_code: process.env.META_TEST_CODE, // remove in prod
};
await fetch(
`https://graph.facebook.com/v18.0/${process.env.META_PIXEL_ID}/events?access_token=${process.env.META_CAPI_TOKEN}`,
{ method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' } }
);
}
const sha256 = (str) => crypto.createHash('sha256').update(str).digest('hex');
Important: use the same event_id for both the browser pixel event and the CAPI event. Meta uses this for deduplication — without it, you'll double-count conversions.
The Behavioral Signal Layer (Bonus)
Once you have a reliable first-party event pipeline, you can capture pre-conversion signals that standard pixels completely ignore.
These micro-behaviors predict purchase intent with meaningful accuracy:
// High-intent signals
const HIGH_INTENT = {
pricingDwell: () => {
// Track time spent on pricing/shipping pages
if (!document.querySelector('.pricing, .shipping-info')) return;
const start = Date.now();
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
const dwell = Date.now() - start;
if (dwell > 30_000) sendSignal('pricing_dwell', { ms: dwell });
}
});
},
imageZoom: () => {
document.querySelectorAll('.product-image').forEach(img => {
img.addEventListener('wheel', throttle(() => sendSignal('image_zoom'), 2000));
});
},
reviewDwell: () => {
const reviews = document.querySelector('.reviews-section');
if (!reviews) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
const start = Date.now();
setTimeout(() => {
if (document.querySelector('.reviews-section:hover') ||
entry.isIntersecting) {
sendSignal('review_dwell', { ms: Date.now() - start });
}
}, 20_000);
}
});
observer.observe(reviews);
},
};
// Friction signals
const FRICTION = {
rageClick: () => {
let clicks = [];
document.addEventListener('click', (e) => {
const now = Date.now();
clicks = clicks.filter(t => now - t < 2000);
clicks.push(now);
if (clicks.length >= 3) {
sendSignal('rage_click', { element: e.target.className });
clicks = [];
}
});
},
paymentError: () => {
// Watch for error messages near payment forms
const observer = new MutationObserver(() => {
const errors = document.querySelectorAll('.payment-error, .error-message, [class*="error"]');
if (errors.length > 0) sendSignal('payment_error');
});
observer.observe(document.body, { childList: true, subtree: true });
},
};
Push these signals to Meta CAPI as custom events. Meta's algorithm will start finding more users who behave like high-intent visitors, not just users who already bought.
What We Packaged Into Zyro
We turned all of the above into a single script tag at www.zyro.world — auto-intercepts 20+ pixels, handles ITP-resistant identity persistence, tracks 90+ attribution sources, captures 26 behavioral signals, and dispatches to Meta, Google, TikTok, Klaviyo, and 11+ other platforms simultaneously.
But everything in this post is implementable from scratch. The DIY path costs roughly:
| Component | Eng Days |
|---|---|
| Pixel wrapping + queue | 1–2 days |
| First-party edge endpoint | 1 day |
| ITP-resistant cookie | 0.5 days |
| Meta CAPI forwarding | 1 day |
| Google Measurement Protocol | 1 day |
| Behavioral signal capture | 2–3 days |
Total: ~7–8 days of engineering. Worth it if you're spending meaningful money on ads.
Gotchas I Wish Someone Had Told Me
Deduplication is critical. If your browser pixel fires AND your CAPI fires for the same event with different event IDs, Meta counts two conversions. Use a stable event_id (user ID + event name + session timestamp works).
keepalive: true on fetch. Without this, your events queue gets dropped when users navigate away mid-flight. keepalive: true tells the browser to complete the request even after the page unloads.
Consent first. If you're in the EU, you must check consent before capturing anything. Don't queue events before a positive consent signal. We check window.__tcfapi consent before initializing the hunting loop.
Deep copy the args. Some pixel libraries mutate their arguments after calling the function. JSON.parse(JSON.stringify(args)) ensures you capture the state at call time, not at flush time.
What approaches are others using for server-side attribution? Particularly curious if anyone's built their own fractional attribution model rather than relying on platform-reported numbers.
Top comments (0)