If you run an ecommerce store and send traffic from Facebook or Instagram, the browser Pixel alone no longer sees every purchase. Safari's tracking protection, ad blockers, consent banners and short-lived cookies all chip away at it. The answer Meta gives is the Conversions API (CAPI): send the same events from your server.
Most CAPI tutorials stop at "POST some JSON". In production, three details decide whether it helps or hurts: deduplication, hashing/normalisation and match quality. This post covers all three with a small Node.js implementation.
Context: our team works on peptide Meta ads, a regulated category where every purchase signal matters because audiences are narrow. Nothing below is niche-specific, though. It applies to any store.
The architecture in one paragraph
The browser fires fbq('track', 'Purchase', ...) on the thank-you page. Your backend, on the order-paid webhook, sends a Purchase event to POST https://graph.facebook.com/{API_VERSION}/{PIXEL_ID}/events. Both carry the same event_id. Meta receives two copies, sees the matching event_name + event_id, and keeps one. You get the coverage of the server and the context of the browser without double counting.
Step 1: Generate one event ID per order
Use something stable that both sides know. The order ID is ideal:
// shared helper
export const purchaseEventId = (orderId) => `purchase_${orderId}`;
On the thank-you page:
<script>
fbq('track', 'Purchase',
{ value: 129.00, currency: 'USD', content_ids: ['SKU-123'], content_type: 'product' },
{ eventID: 'purchase_100045' } // same string the server will send
);
</script>
Note the casing: the browser option is eventID; the server field is event_id.
Step 2: Normalise, then hash
CAPI expects personal data hashed with SHA-256, after normalisation. Hashing an un-normalised value is the most common reason match quality stays low, because " John@Mail.com " and "john@mail.com" produce different hashes.
import crypto from 'node:crypto';
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
const norm = {
email: (e) => e.trim().toLowerCase(),
phone: (p) => p.replace(/\D/g, ''), // digits only, include country code
name: (n) => n.trim().toLowerCase(),
zip: (z) => z.trim().toLowerCase().replace(/\s/g, ''),
country: (c) => c.trim().toLowerCase(), // ISO-3166 alpha-2, e.g. "us"
};
const hashed = (fn, v) => (v ? [sha256(fn(v))] : undefined);
Do not hash client_ip_address, client_user_agent, fbc or fbp. They are sent as plain values.
Step 3: Capture fbp and fbc at checkout
_fbp is the browser ID cookie set by the Pixel. _fbc holds the click ID when someone arrives from an ad (fbclid in the URL). Save both on the order when checkout starts, because your webhook runs later, without the browser.
// at checkout creation (server side, request available)
order.meta = {
fbp: req.cookies._fbp,
fbc: req.cookies._fbc
|| (req.query.fbclid ? `fb.1.${Date.now()}.${req.query.fbclid}` : undefined),
ip: req.ip,
ua: req.get('user-agent'),
sourceUrl: req.get('referer'),
};
Step 4: Send the server event
const PIXEL_ID = process.env.META_PIXEL_ID;
const TOKEN = process.env.META_CAPI_TOKEN;
const API_VERSION = process.env.META_API_VERSION; // use the current Graph API version
export async function sendPurchase(order) {
const body = {
data: [{
event_name: 'Purchase',
event_time: Math.floor(new Date(order.paidAt).getTime() / 1000),
event_id: purchaseEventId(order.id),
action_source: 'website',
event_source_url: order.meta.sourceUrl,
user_data: {
em: hashed(norm.email, order.email),
ph: hashed(norm.phone, order.phone),
fn: hashed(norm.name, order.firstName),
ln: hashed(norm.name, order.lastName),
zp: hashed(norm.zip, order.zip),
country: hashed(norm.country, order.country),
external_id: [sha256(String(order.customerId))],
client_ip_address: order.meta.ip,
client_user_agent: order.meta.ua,
fbp: order.meta.fbp,
fbc: order.meta.fbc,
},
custom_data: {
currency: order.currency,
value: order.total,
order_id: String(order.id),
content_type: 'product',
content_ids: order.items.map((i) => i.sku),
num_items: order.items.reduce((n, i) => n + i.qty, 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(body) }
);
if (!res.ok) throw new Error(`CAPI ${res.status}: ${await res.text()}`);
return res.json(); // { events_received: 1, fbtrace_id: ... }
}
Call it from the payment webhook, not from the thank-you page, so a closed tab never loses a sale. Put it on a queue with retries: Meta accepts events up to 7 days old, but delivery is most useful within an hour.
Step 5: Check it in Events Manager
In Events Manager, open the Pixel's Overview:
- Purchase should show Browser and Server as connection methods.
- The deduplication column should show most server events matched to browser events. If server and browser counts are both high and nothing is deduplicated, your
event_idvalues do not match. -
Event Match Quality (EMQ) scores how well your
user_dataidentifies people. Email, phone,fbp,fbc, IP and user agent together usually lift Purchase to a strong score.
Common bugs we see
| Symptom | Cause |
|---|---|
| Purchases doubled in Ads Manager |
eventID missing on the browser call, or different formats on each side |
| Low match quality | Hashing before normalising; phone without country code |
| Server events "too old" | Sending event_time in milliseconds instead of seconds |
No fbc on most orders |
Not persisting fbclid from the landing URL through checkout |
| Values in the wrong currency | Sending store currency while the Pixel reports customer currency |
Why it matters for the ad side
Meta's delivery system optimises toward the conversions it can see. When CAPI recovers the purchases the Pixel misses, the algorithm learns from the full set of real buyers, finds more people like them, and reports ROAS you can trust. For stores in competitive or regulated niches, that difference is often what separates a profitable campaign from one that gets switched off.
If you want the marketing-side view of this setup, we wrote about Pixel and Conversions API for peptide stores.
Mikail builds tracking and Meta ads infrastructure at Adnoxx, a peptide ad agency with years in digital marketing.
adnoxx.com · peptideads@adnoxx.com · Telegram @adnoxxads · WhatsApp +1 681 379 0505 · Instagram @adnoxxofficial
Top comments (0)