Browser pixels miss events. Ad blockers, tracking prevention in Safari and Firefox, and users who close the tab before the request fires all remove conversions from your reporting. The Meta Conversions API sends the same events from your server, where none of that applies. Done properly, you recover data. Done carelessly, you double-count every purchase and make your reporting worse than before.
Send both, and deduplicate
The intended setup is not server-only. You keep the browser pixel and add server events for the same actions, then let Meta discard the duplicates. Deduplication depends on two fields matching across both sends: the event name and the event ID.
// Browser
const eventId = crypto.randomUUID();
fbq('track', 'Purchase', { value: 79.0, currency: 'USD' }, { eventID: eventId });
// Server — same event name, same event_id
await sendConversion({
event_name: 'Purchase',
event_id: eventId,
event_time: Math.floor(Date.now() / 1000),
action_source: 'website',
});The event ID must be generated once and shared, not generated independently on each side. In practice that means the browser creates it and passes it to your server with the order request, or your server creates it and returns it in the page payload. If the IDs differ, Meta treats them as two separate conversions and your cost per purchase halves on paper while your bank balance does not.
User data has to be hashed, and hashed correctly
Server events match to people through hashed identifiers. Email, phone, first name, last name, city, state, postcode and country are all sent as SHA-256 hashes. Normalisation matters as much as the hash: trim whitespace, lowercase everything, strip punctuation from phone numbers, and use the E.164 format with no plus sign.
import { createHash } from 'crypto';
const hash = (value: string) =>
createHash('sha256').update(value.trim().toLowerCase()).digest('hex');
const userData = {
em: [hash('Buyer@Example.com')], // buyer@example.com
ph: [hash('+1 307 289 2860'.replace(/\D/g, ''))],
client_ip_address: request.ip,
client_user_agent: request.headers['user-agent'],
};Two fields people routinely forget are client_ip_address and client_user_agent. They must come from the original browser request, not from your server. If you send your server's IP and a Node user agent string, match quality drops and Meta will tell you so in Events Manager.
Also forward the fbp and fbc cookie values when they exist. They are the strongest match signals available and they cost nothing to include.
Consent is not optional plumbing
Moving events server-side does not move you outside consent law. If a visitor has not opted into marketing cookies, you should not be sending their identifiers to Meta from the browser or from your server. Build the consent state into the server call explicitly rather than assuming the pixel gate covers it — the server does not read the cookie banner.
The pattern we use: the consent decision is stored client-side, sent with the conversion request, and validated server-side before the event is queued. No consent means the event is dropped, or sent without user identifiers where the platform and your legal advice allow it.
Where the server call should live
Do not call the Conversions API from the browser. The access token is a credential; anything shipped to the browser is public. Put the call in a server route or server function, keep the token in your platform's secret store, and never log the request body containing hashed user data.
Make the call non-blocking with respect to your user. A checkout confirmation should not wait on Meta's API. Fire the event after the order is committed, handle the failure case by logging it, and retry idempotently — the event ID makes retries safe.
Verify before you trust it
- Use the Test Events tab in Events Manager with a test_event_code while developing; remove the code before going live.
- Check the Event Match Quality score per event — anything under about 6 usually means missing identifiers rather than a broken integration.
- Confirm the deduplication rate in the event details view. Seeing both browser and server events with a high deduplication share is the correct outcome.
- Compare the platform's reported purchase count with your own database for the same date range for at least a week.
What good looks like
After a correct implementation you should see slightly higher reported conversions than the browser pixel alone, a stable deduplication rate, a match quality score in the good range, and your own order count as the ceiling that platform numbers approach but never exceed. If reported conversions jump above your real order count, stop and check the event IDs first — it is almost always deduplication.