Collect
Meta Conversions API explained
Meta Conversions API explained. The browser Pixel can miss an event. A server you control can send the same action, with a shared id so Meta counts it once. This page builds that JSON and stops.
A server call beside the Pixel
The Meta Pixel is JavaScript in the page. It calls Meta from the browser. The Conversions API is the same kind of event, posted by your server to graph.facebook.com with a Pixel id and an access token.
You can run both. The Pixel still sees the browser session. The server still sees the action when the browser call never leaves the device. Meta then needs a way to tell those two reports apart from two different actions.
The Server-side lab is the same idea for a first-party beacon. The Offline conversions lab is Google’s Measurement Protocol. This page is Meta’s server event.
Why the browser copy is not enough
An ad blocker, a strict browser, or a declined cookie can stop the Pixel. The purchase can still be real. A server that already recorded the order can tell Meta without asking the browser to finish the call.
Match quality is how well Meta can join that event to an account. Hashed email and phone help. So do the click id, the browser id, the IP address, and the user agent. A thin payload still arrives. It is harder to attribute.
The access token stays on the server. The browser should never see it. Hash email and phone once, before Meta sees them. Do not hash the IP, the user agent, fbp, or fbc.
What the JSON contains
The body is data, an array of events. Each event has event_name, event_time in unix seconds, event_id, action_source set to website, and event_source_url.
user_data.em and user_data.ph are arrays of SHA-256 hex. fbp, fbc, client_ip_address, and client_user_agent are sent in the clear. custom_data can carry value and currency.
test_event_code sits beside data, not inside the event. Events Manager shows those hits under Test events. Leave it off for production traffic.
Dry run
Pick an event and, if you want, type an email or phone. Hashing runs in this browser. The JSON is what a consented call would contain. Nothing is posted to Meta, and the address and phone stay in this tab.
Normalized, then hashed
Email is trimmed and lowercased. Phone keeps digits, drops leading zeros, and keeps the country code. fbp, fbc, IP, and user agent stay as-is. Empty email and phone are left out.
- Normalized email
- —
- SHA-256 em
- —
- Normalized phone
- —
- SHA-256 ph
- —
Sample fields, not this browser: IP 203.0.113.10, fbp fb.1.1710000000000.1234567890, fbc fb.1.1710000000000.IwAR_sample_click_id. User agent: Mozilla/5.0 (compatible; CAPI-Lab-Sample/1.0). Page URL in the payload: https://www.example.com/checkout.
Pixel
The browser call uses eventID. The server event uses event_id. Same value, same event name.
Conversions API
Consent is on in this preview. This page still does not post the body.
One action, two copies
Meta drops the later copy when the Pixel event and the server event share event name and event id, reach the same Pixel id, and arrive within 48 hours. If they arrive within about 5 minutes, Meta keeps the browser event.
Pixel
event Purchase
eventID …
same id
Conversions API
event_name Purchase
event_id …
Result in Events Manager: one Purchase, not two. Two browser-only copies, or two server-only copies, are not dropped by this rule.
Match checklist
0 of 0 teaching keys are present. Events Manager computes Event Match Quality. This list is only which fields the preview would send.
Consent still applies
A server event is still measurement. If the visitor declined, or Global Privacy Control is on, do not send the event. Do not attach email, phone, or a click id you would not have been allowed to read.
On this site, Google Analytics waits for Accept, and Global Privacy Control forces measurement off. The dry run follows the same switch for the preview. It never stores the address or the phone. The Consent lab is the control itself.
Conversions API Gateway
Gateway is Meta’s hosted path. You point a subdomain you control at it, and the browser can forward events there without you running the Graph call. You still need consent, and you still share an event id with the Pixel when both are on. The dry run above is the shape of the payload either way. This site does not run a Gateway.
Copy-ready samples
These are text. PIXEL_ID and META_CAPI_ACCESS_TOKEN are names for your own server. The Graph version in the URL is an example. Use the version named in the current Meta docs. This website has no route that accepts an email for Meta.
Node
Hash, build the body, and stop. The fetch to Graph is commented out.
// Display only. This lab does not run the request.
// Hash on your server. Keep META_CAPI_ACCESS_TOKEN off the page.
import { createHash, randomUUID } from "node:crypto"
const sha256 = (value) => createHash("sha256").update(value).digest("hex")
const email = "ada@example.com".trim().toLowerCase()
const phone = "16505551212"
const eventId = randomUUID()
const body = {
data: [
{
event_name: "Purchase",
event_time: Math.floor(Date.now() / 1000),
event_id: eventId,
action_source: "website",
event_source_url: "https://www.example.com/checkout",
user_data: {
em: [sha256(email)],
ph: [sha256(phone)],
client_ip_address: "203.0.113.10",
client_user_agent: "Mozilla/5.0 (compatible; Example/1.0)",
fbp: "fb.1.1710000000000.1234567890",
fbc: "fb.1.1710000000000.IwAR_sample_click_id",
},
custom_data: { value: 42.5, currency: "USD" },
},
],
test_event_code: "TEST12345",
}
const pixelId = process.env.PIXEL_ID
const token = process.env.META_CAPI_ACCESS_TOKEN
const url = `https://graph.facebook.com/v21.0/${pixelId}/events?access_token=${token}`
// Replace v21.0 with the version in the current Conversions API docs.
// await fetch(url, {
// method: "POST",
// headers: { "content-type": "application/json" },
// body: JSON.stringify(body),
// })
console.log(url)
console.log(JSON.stringify(body, null, 2))
Python
The standard library hashes the same fields. The POST stays commented out.
# Display only. This lab does not run the request.
# Hash before the call. Keep the token in the environment.
import hashlib, json, os, time, uuid
def sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
event_id = str(uuid.uuid4())
body = {
"data": [
{
"event_name": "Purchase",
"event_time": int(time.time()),
"event_id": event_id,
"action_source": "website",
"event_source_url": "https://www.example.com/checkout",
"user_data": {
"em": [sha256("ada@example.com".strip().lower())],
"ph": [sha256("16505551212")],
"client_ip_address": "203.0.113.10",
"client_user_agent": "Mozilla/5.0 (compatible; Example/1.0)",
"fbp": "fb.1.1710000000000.1234567890",
"fbc": "fb.1.1710000000000.IwAR_sample_click_id",
},
"custom_data": {"value": 42.5, "currency": "USD"},
}
],
"test_event_code": "TEST12345",
}
pixel_id = os.environ["PIXEL_ID"]
token = os.environ["META_CAPI_ACCESS_TOKEN"]
url = f"https://graph.facebook.com/v21.0/{pixel_id}/events?access_token={token}"
# Replace v21.0 with the version in the current Conversions API docs.
# urllib.request.urlopen(urllib.request.Request(
# url, data=json.dumps(body).encode(), headers={"content-type": "application/json"}, method="POST"
# ))
print(url)
print(json.dumps(body, indent=2))
Next.js route handler
A route you would host yourself. It returns the body when the token is unset, and it does not live on this site.
// Display only. Do not add this file on this site.
// app/api/capi/route.ts on a server you control.
import { createHash, randomUUID } from "node:crypto"
import { NextResponse } from "next/server"
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex")
export async function POST(request: Request) {
const input = (await request.json()) as {
event_name?: string
event_id?: string
email?: string
phone?: string
value?: number
currency?: string
event_source_url?: string
client_ip_address?: string
client_user_agent?: string
fbp?: string
fbc?: string
}
const email = input.email?.trim().toLowerCase()
const phone = input.phone?.replace(/\D/g, "").replace(/^0+/, "")
const eventId = input.event_id || randomUUID()
const userData: Record<string, string | string[]> = {}
if (email) userData.em = [sha256(email)]
if (phone) userData.ph = [sha256(phone)]
if (input.client_ip_address) userData.client_ip_address = input.client_ip_address
if (input.client_user_agent) userData.client_user_agent = input.client_user_agent
if (input.fbp) userData.fbp = input.fbp
if (input.fbc) userData.fbc = input.fbc
const body = {
data: [
{
event_name: input.event_name || "Purchase",
event_time: Math.floor(Date.now() / 1000),
event_id: eventId,
action_source: "website",
event_source_url: input.event_source_url,
user_data: userData,
custom_data:
input.value != null ? { value: input.value, currency: input.currency || "USD" } : undefined,
},
],
}
const pixelId = process.env.PIXEL_ID
const token = process.env.META_CAPI_ACCESS_TOKEN
if (!pixelId || !token) return NextResponse.json({ sent: false, event_id: eventId, body })
const version = "v21.0" // replace with the version in the current docs
const response = await fetch(
`https://graph.facebook.com/${version}/${pixelId}/events?access_token=${token}`,
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
)
return NextResponse.json({ sent: response.ok, event_id: eventId })
}
Browser snippet
One event id for fbq and for your server. The server hashes. This page does not run the snippet.
// Display only. Send the same event_id to the Pixel and to your server.
// Your server hashes email and phone. The browser never sees the access token.
// This page does not call fbq and does not POST this body.
const eventId = crypto.randomUUID()
fbq("track", "Purchase", { value: 42.5, currency: "USD" }, { eventID: eventId })
await fetch("/api/capi", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
event_name: "Purchase",
event_id: eventId,
event_source_url: location.href,
email: "ada@example.com",
phone: "+1 650 555 1212",
value: 42.5,
currency: "USD",
fbp: document.cookie.match(/(?:^|; )_fbp=([^;]+)/)?.[1],
fbc: document.cookie.match(/(?:^|; )_fbc=([^;]+)/)?.[1],
}),
})
The same idea on other networks
Each network names the pieces differently. The pattern is a browser tag, a server call, a shared id, and hashed contact data.
| Network | Browser | Server | Shared id | Contact data |
|---|---|---|---|---|
| Meta | Pixel | Conversions API | event_id with event_name | SHA-256 email and phone |
| gtag | Enhanced Conversions and Measurement Protocol | client_id, plus transaction_id on a purchase | SHA-256 for Enhanced Conversions | |
| TikTok | Pixel | Events API | event_id | SHA-256 email and phone |
| Insight Tag | Conversions API | a conversion event id | SHA-256 email | |
| Snapchat | Pixel | Conversions API | client_dedup_id | SHA-256 email and phone |
Meta’s docs
Recommended next
Why these picksReading path transitions…