Skip to content
Black BoxPersonalization

Last updated

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.

    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.

    NetworkBrowserServerShared idContact data
    MetaPixelConversions APIevent_id with event_nameSHA-256 email and phone
    GooglegtagEnhanced Conversions and Measurement Protocolclient_id, plus transaction_id on a purchaseSHA-256 for Enhanced Conversions
    TikTokPixelEvents APIevent_idSHA-256 email and phone
    LinkedInInsight TagConversions APIa conversion event idSHA-256 email
    SnapchatPixelConversions APIclient_dedup_idSHA-256 email and phone

    Meta’s docs

    Why these picks

    Reading path transitions…

    Questions this lab answers

    What is the Meta Conversions API?

    It is a server-to-server event to Meta, using a Pixel id and an access token. The Pixel is the same kind of event sent from the browser.

    How do the Pixel and the server event stay one action?

    Send the same event name and the same event id from both. Meta drops the later copy when both reach the same Pixel id within 48 hours.

    Which customer fields are hashed?

    Email and phone are SHA-256 after normalization. The click id, browser id, IP address, and user agent are not hashed.

    Does this lab send the event to Meta?

    No. Hashing runs in the browser. The JSON is shown on the page. No email or phone is stored, and the page works with no database.

    What is a test event code?

    A code from Events Manager Test events. It sits next to the data array so Meta files the hit as a test. Leave it off for live traffic.

    Related reading

    • Paid and social measurement

      Paid and social measurement for every ad platform: one taxonomy, UTM rules, aligned tags, and a free dry run that sends nothing and stores nothing.

    • Free analytics & personalization

      Free analytics & personalization in this browser: the page, the requests, and the record a first-party beacon leaves on the server. Open any lab.

    • What a beacon actually sends

      The body of one page view, the gate it has to pass, and the record that comes back.