The browser pixel misses conversions. Ad blockers strip it, Safari's tracking prevention limits the cookies it depends on, and users who decline consent never fire it at all. How much you lose varies enormously by audience — a developer-heavy B2B audience blocks far more than a general consumer one — so treat any universal percentage you read with suspicion, including in this article. Measure your own gap.
The Conversions API is Meta's server-side tracking approach — it sends events server-to-server instead. Your server tells Meta directly that a conversion happened, which no client-side blocker can prevent.
The correct setup runs both, deduplicated. Not CAPI instead of the pixel.
Why both, not one
The browser pixel captures signals your server doesn't have — the _fbp and _fbc cookies Meta uses for attribution, plus browser and page context. The server sees conversions the browser never reports.
Running both with proper deduplication gives you the union: every event reported at least once, none counted twice. That's the point of the event_id field.
Normalising and hashing
This is where most implementations are quietly broken. Customer data must be normalised first, then SHA-256 hashed. Hash an un-normalised value and Meta cannot match it — the event is accepted, reports no error, and silently fails to attribute.
The rules that matter:
- Everything lowercased and trimmed before hashing
- Email: lowercase, trim
- Phone: digits only, with country code, no leading
+, no spaces or punctuation - Names: lowercase, trim, no punctuation
- Country/state: lowercase two-letter codes
// lib/meta-capi.ts
import { createHash } from "crypto";
const sha256 = (value: string) =>
createHash("sha256").update(value).digest("hex");
export function hashEmail(email?: string) {
if (!email) return undefined;
return sha256(email.trim().toLowerCase());
}
export function hashPhone(phone?: string) {
if (!phone) return undefined;
// Digits only, country code included, no leading "+".
const digits = phone.replace(/\D/g, "");
if (digits.length < 7) return undefined;
return sha256(digits);
}Never send raw email addresses or phone numbers. Meta expects hashes for all user_data identifiers except client_ip_address, client_user_agent, fbp, and fbc, which are sent in the clear.
The server route
// app/api/events/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hashEmail, hashPhone } from "@/lib/meta-capi";
export const runtime = "nodejs";
const schema = z.object({
eventName: z.enum(["Lead", "Purchase", "CompleteRegistration", "Contact"]),
eventId: z.string().min(1), // must match the pixel's eventID
eventSourceUrl: z.string().url(),
email: z.string().email().optional(),
phone: z.string().optional(),
fbp: z.string().optional(), // _fbp cookie
fbc: z.string().optional(), // _fbc cookie
});
export async function POST(request: NextRequest) {
const parsed = schema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: "Invalid event" }, { status: 400 });
}
const d = parsed.data;
const pixelId = process.env.META_PIXEL_ID;
const token = process.env.META_CAPI_TOKEN;
if (!pixelId || !token) {
return NextResponse.json({ error: "Not configured" }, { status: 500 });
}
const body = {
data: [
{
event_name: d.eventName,
event_time: Math.floor(Date.now() / 1000),
event_id: d.eventId, // deduplication key
event_source_url: d.eventSourceUrl,
action_source: "website",
user_data: {
em: hashEmail(d.email),
ph: hashPhone(d.phone),
fbp: d.fbp,
fbc: d.fbc,
client_ip_address:
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
client_user_agent: request.headers.get("user-agent") ?? undefined,
},
},
],
};
const res = await fetch(
`https://graph.facebook.com/v21.0/${pixelId}/events?access_token=${token}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!res.ok) {
// Log server-side; never leak the response to the client.
console.error("capi.failed", { status: res.status, detail: await res.text() });
return NextResponse.json({ ok: false }, { status: 502 });
}
return NextResponse.json({ ok: true });
}Three things worth noting. The access token goes in the query string or request body, never a client-visible place — it's a long-lived credential with write access to your ad account. Event time must be a Unix timestamp in seconds, not milliseconds; getting this wrong pushes events outside Meta's accepted window and they're dropped. And the route validates its input, because it's a public endpoint anyone can post to.
Deduplication
Both pixel and server must send the same event_id and event_name. Meta then treats them as one event.
Generate the ID once, use it in both places:
"use client";
export function trackLead(email: string, phone: string) {
const eventId = crypto.randomUUID();
// 1. Browser pixel
window.fbq?.("track", "Lead", {}, { eventID: eventId });
// 2. Server, same ID
fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eventName: "Lead",
eventId,
eventSourceUrl: window.location.href,
email,
phone,
fbp: getCookie("_fbp"),
fbc: getCookie("_fbc"),
}),
});
}Note eventID — capital ID — in the fbq call. It's event_id server-side and eventID client-side, and mixing them up is a common silent failure.
Pass through _fbp and _fbc whenever available. They're Meta's strongest attribution signals, and a server-only event without them matches far more weakly.
For events that happen with no browser present — a CRM status change, a payment webhook, an offline sale — send server-only with action_source set appropriately and no event_id collision to worry about.
Consent
This is a legal requirement, not an optional refinement.
Sending hashed identifiers server-side is still processing personal data. Hashing is pseudonymisation, not anonymisation — that's the settled position under GDPR, and moving the call to your server does not remove the consent obligation. If anything it makes it more visible, because the user's browser can no longer opt out on their behalf.
Practically:
- Check consent before firing, on both paths. If your CMP hasn't recorded marketing consent, send nothing.
- Configure Meta's Consent Mode signals if you use a consent platform.
- Record what was consented to and when. You may have to demonstrate it.
- Document CAPI in your privacy policy, explicitly — server-side sharing with Meta is not obvious to users and regulators have taken an interest in exactly this.
An implementation that ignores consent is a compliance liability that outweighs any attribution gain.
Verifying it works
Test Events in Events Manager shows events arriving in real time. Add the test_event_code parameter during development and watch them land.
Then check Event Match Quality per event. It scores how well your user_data matches Meta's records. Low scores mean your normalisation or hashing is wrong — usually phone formatting. Adding more identifiers (email and phone and fbp) raises it.
Finally, confirm deduplication is working. Events Manager reports deduplicated counts; if your Lead count roughly doubled after adding CAPI, your event_id values aren't matching and you're double-counting.
Want this set up correctly?
We implement server-side tracking with correct hashing, deduplication, and consent handling — the parts that decide whether the data is usable or just present. Talk to us about digital marketing.