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 you need it, the timestamp changes between the browser event and the server event. Meta sees two different fbc values for the same click and deduplication breaks. Your EMQ score drops from 8 to 5, and your cost per result climbs.
The fix: capture fbc once, freeze the timestamp, and persist it across sessions with dual-write to both cookie and localStorage.
// tracking.js — runs on LP load
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 '';
}
function ensureFbp() {
const cookieFbp = getCookie('_fbp');
if (cookieFbp) return cookieFbp;
const stored = localStorage.getItem('fbp');
if (stored) {
document.cookie = `_fbp=${stored}; max-age=${90 * 86400}; path=/; SameSite=Lax`;
return stored;
}
const rand10 = Math.floor(Math.random() * 9e9) + 1e9;
const fbp = `fb.1.${Date.now()}.${rand10}`;
localStorage.setItem('fbp', fbp);
document.cookie = `_fbp=${fbp}; max-age=${90 * 86400}; path=/; SameSite=Lax`;
return fbp;
}
Why dual-write cookie + localStorage? Safari ITP and Firefox ETP clear first-party cookies set by JavaScript after 7 days. If a user clicks an ad, leaves, and comes back on day 10, their _fbc cookie is gone. But localStorage survives. The dual-write acts as a fallback chain: cookie first (because Meta's own SDK reads cookies), localStorage as backup (because browsers don't auto-purge it).
Step 2: The Browser-Side Pixel (GTM Web Container)
The GTM web container handles the browser-side fire. We use a Custom HTML tag that pushes a dataLayer event with all the parameters the server side will also need.
When a conversion event fires (e.g., CTA click leading to registration):
// In the LP's click handler
function handleCtaClick(ctaPosition) {
const eventId = crypto.randomUUID();
const fbc = ensureFbc();
const fbp = ensureFbp();
// 1. Browser-side: fire Pixel
fbq('track', 'Lead', {
content_name: ctaPosition,
}, { eventID: eventId });
// 2. Push to dataLayer for GTM GA4 tag
window.dataLayer.push({
event: 'click_reg',
eventID: eventId,
content_name: ctaPosition,
fbc: fbc,
fbp: fbp,
});
// 3. Server-side: fire CAPI via Cloud Function
sendCAPIEvent('Lead', {
event_id: eventId,
fbc: fbc,
fbp: fbp,
content_name: ctaPosition,
event_source_url: window.location.href,
user_agent: navigator.userAgent,
});
}
The critical detail: eventID in the browser fbq() call must exactly match event_id in the CAPI payload. This is how Meta deduplicates. Use crypto.randomUUID() — it's available in all modern browsers and produces collision-free IDs.
Step 3: The Cloud Function (Server-Side CAPI)
The Cloud Function receives the event data from the LP and forwards it to Meta's Conversions API. We deploy this on Google Cloud Functions (Node.js 20, asia-east1 region for low latency to Meta's APAC endpoints).
The function builds the Meta CAPI event payload with event_name, event_time, event_id, user data (fbc, fbp, client_user_agent, client_ip_address), and fires to all configured pixels in parallel using Promise.allSettled.
Why a dedicated Cloud Function instead of sGTM? We've run both. Server-side GTM (sGTM) with community templates like Stape's Meta CAPI tag has a nasty default behavior: unknown events get mapped to Lead. That means every page_view, scroll, and user_engagement event flowing through your server container turns into a phantom Lead. We've seen LPs reporting 350 Leads/day when actual registrations were 7. The inflation ratio was 50:1.
With a dedicated Cloud Function, you control exactly which events get sent and what they're called. There's no "default event name" footgun.
Step 4: The LP-Side Sender
The LP calls the Cloud Function with navigator.sendBeacon for fire-and-forget reliability (the event still sends even if the user navigates away immediately after clicking).
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 with keepalive fail silently. Always verify server-side delivery by checking Meta's Pixel diagnostics or querying the Graph API.
Lessons Learned in Production
After running this system across multiple client LPs, here's what bit us:
1. GTM Lookup Table defaultValue = "Lead" is a disaster. If you use a GTM Lookup Table variable to map dataLayer event names to Meta standard events, never set the default value to Lead. Every GTM built-in event that doesn't match your lookup will fire as a Lead. Set the default to an empty string and add an explicit trigger filter like ^click_reg$.
2. Cloud Function cold starts can delay server events by 2-3 seconds. Meta allows up to a 5-minute delay between event_time and delivery, so this is fine for attribution. Use min_instances: 1 in production to keep the function warm.
3. client_ip_address is required for EMQ but tricky behind CDNs. Cloud Functions give you req.ip, but if the LP is behind Cloudflare or Firebase Hosting with a CDN, you'll get the CDN's IP, not the user's. Pass X-Forwarded-For from the LP request to the Cloud Function.
4. Dual-pixel setups need separate access tokens. Each pixel must be written with a token that has ads_management scope and is asset-assigned to that specific pixel in Business Manager. A token assigned to Pixel A can't write events to Pixel B, even if they're in the same BM.
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 lives in the details: freezing fbc timestamps, surviving ITP cookie purges, avoiding phantom Lead inflation from overly broad GTM triggers, and silently validating that the server channel is actually delivering.
If you're running performance campaigns where every conversion matters — especially in verticals like iGaming, crypto, or forex where ad accounts are volatile and tracking breakage directly translates to wasted spend — this kind of infrastructure pays for itself within the first week.
Built by RedClaw
This tracking architecture is part of the growth infrastructure we build at RedClaw, a performance marketing agency specializing in iGaming, crypto, and forex verticals. We handle media buying, landing page development, and the tracking plumbing that connects them.
If you're dealing with Meta tracking issues, ad account instability, or need a CAPI setup that actually holds up in production, reach out.
Top comments (0)