If you run paid acquisition on Meta for landing pages, you already know the problem: iOS 14.5 gutted browser-side tracking. Meta's answer is the Conversions API (CAPI) — server-side event delivery that bypasses ad blockers and browser restrictions. But "just add CAPI" is about 20% of the actual work. The other 80% is deduplication, cookie persistence across sessions, and making sure your Event Match Quality (EMQ) score doesn't tank because your fbc parameter keeps changing.
This article walks through the dual-fire tracking system we built at RedClaw for high-traffic iGaming landing pages. We'll cover the architecture, the GTM setup, the Cloud Function that sends server-side events, and the specific bugs that cost us days of debugging.
Architecture Overview
The system has three layers:
User lands on LP (with ?fbclid=xxx)
|
v
[Browser Layer]
GTM Web Container --> fbq('track', 'Lead')
+ dataLayer push with eventID, fbc, fbp
|
v
[Server Layer]
LP JavaScript --> POST to Cloud Function
Cloud Function --> Meta Conversions API
(same eventID for dedup)
|
v
[Meta receives both events, deduplicates by event_id]
Both the browser Pixel and the server CAPI send the same event with the same event_id. Meta deduplicates them. If the browser event gets blocked (ad blocker, ITP, network failure), the server event still lands. If the server event fails (function cold start, timeout), the browser event covers it. That's the dual-fire guarantee.
Step 1: Capturing fbclid and Building Persistent fbc/fbp
This is where most implementations silently fail. The fbc (click ID) and fbp (browser ID) parameters are the backbone of Meta's attribution. If you reconstruct fbc with Date.now() every time, the timestamp changes between browser and server events. Meta sees two different fbc values and deduplication breaks. Your EMQ score drops from 8 to 5.
The fix: capture fbc once, freeze the timestamp, and persist it across sessions with dual-write to both cookie and localStorage.
function captureFbclid() {
const params = new URLSearchParams(window.location.search);
const fbclid = params.get('fbclid');
if (!fbclid) return;
localStorage.setItem('fbclid', fbclid);
const fbc = 'fb.1.' + Date.now() + '.' + fbclid;
localStorage.setItem('fbc', fbc);
document.cookie = '_fbc=' + fbc + '; max-age=' + (90*86400) + '; path=/; SameSite=Lax';
}
function ensureFbc() {
const cookieFbc = getCookie('_fbc');
if (cookieFbc) return cookieFbc;
const storedFbc = localStorage.getItem('fbc');
if (storedFbc) {
document.cookie = '_fbc=' + storedFbc + '; max-age=' + (90*86400) + '; path=/; SameSite=Lax';
return storedFbc;
}
const fbclid = localStorage.getItem('fbclid');
if (fbclid) {
const fbc = 'fb.1.' + Date.now() + '.' + fbclid;
localStorage.setItem('fbc', fbc);
document.cookie = '_fbc=' + fbc + '; max-age=' + (90*86400) + '; path=/; SameSite=Lax';
return fbc;
}
return '';
}
Why dual-write cookie + localStorage? Safari ITP clears first-party JS cookies after 7 days. If a user clicks an ad and returns on day 10, _fbc is gone. But localStorage survives.
Step 2: The CTA Click Handler
When a conversion fires, we generate a shared event_id and send it to both browser Pixel and server CAPI:
function handleCtaClick(ctaPosition) {
const eventId = crypto.randomUUID();
const fbc = ensureFbc();
const fbp = ensureFbp();
// Browser-side Pixel
fbq('track', 'Lead', {
content_name: ctaPosition,
}, { eventID: eventId });
// Server-side CAPI
sendCAPIEvent('Lead', {
event_id: eventId,
fbc: fbc,
fbp: fbp,
content_name: ctaPosition,
event_source_url: window.location.href,
user_agent: navigator.userAgent,
});
}
eventID in the browser fbq() call must exactly match event_id in the CAPI payload. This is how Meta deduplicates.
Step 3: The Server-Side Sender
The LP calls the Cloud Function with navigator.sendBeacon for fire-and-forget reliability:
function sendCAPIEvent(eventName, params) {
const CAPI_ENDPOINT = 'https://your-function-url.a.run.app';
const payload = JSON.stringify({
event_name: eventName,
event_id: params.event_id,
event_source_url: params.event_source_url || window.location.href,
user_agent: params.user_agent || navigator.userAgent,
fbc: params.fbc || ensureFbc(),
fbp: params.fbp || ensureFbp(),
content_name: params.content_name,
});
const sent = navigator.sendBeacon(CAPI_ENDPOINT, new Blob([payload], { type: 'application/json' }));
if (!sent) {
fetch(CAPI_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, keepalive: true }).catch(() => {});
}
}
Warning: Both sendBeacon and fetch(keepalive) fail silently. Always verify server-side delivery via Meta's Pixel diagnostics or the Graph API.
Why Not sGTM?
Server-side GTM with Stape's Meta CAPI template has a nasty default: unknown events map to Lead. Every page_view, scroll, user_engagement becomes a phantom Lead. We saw LPs reporting 350 Leads/day when actual registrations were 7. With a dedicated Cloud Function, you control exactly which events fire.
Lessons Learned
1. GTM Lookup Table default = "Lead" is a disaster. Set default to empty string, add trigger filter ^click_reg$.
2. Cold starts delay server events 2-3 seconds. Meta allows 5-min delay, so it's fine. Use min_instances: 1 in prod.
3. client_ip_address behind CDNs. Pass X-Forwarded-For from the LP, not req.ip.
4. Dual-pixel = separate tokens. Each pixel needs its own asset-assigned token with ads_management scope.
Wrapping Up
The dual-fire pattern isn't complicated in theory — send the same event from both browser and server, share an event_id, let Meta deduplicate. The complexity is in the details: freezing fbc timestamps, surviving ITP, avoiding phantom Lead inflation, and silently validating server delivery.
If you're running performance campaigns where every conversion matters — especially in iGaming, crypto, or forex where tracking breakage = wasted spend — this infrastructure pays for itself in week one.
Built by RedClaw — performance marketing for iGaming, crypto, and forex. We handle media buying, landing pages, and the tracking plumbing that connects them. Get in touch.
Top comments (0)