DEV Community

Mikail (Adnoxx)
Mikail (Adnoxx)

Posted on

Meta Conversions API in Node.js: Deduplication, Hashing and Event Match Quality Done Right

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}`;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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'),
};
Enter fullscreen mode Exit fullscreen mode

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: ... }
}
Enter fullscreen mode Exit fullscreen mode

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_id values do not match.
  • Event Match Quality (EMQ) scores how well your user_data identifies 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)