DEV Community

Cover image for 6 Reasons Your Meta Conversions API Events Don't Match (and How to Check Yours)
Luke Sandelands
Luke Sandelands

Posted on

6 Reasons Your Meta Conversions API Events Don't Match (and How to Check Yours)

Server-side tracking is meant to fix the conversions a browser pixel loses. But a Conversions API event only helps if Meta can match it to a person, and a lot of setups send events that are accepted, counted as received, and then do much less than they should.

I build server-side tracking for Shopify stores, and the same six mistakes come up again and again. Every rule below comes from Meta's own Conversions API parameter documentation, read on 28 September 2026.

1. event_time is in milliseconds

Meta wants a Unix timestamp in seconds. JavaScript's Date.now() returns milliseconds, so it's the single most common bug:

event_time: Date.now()                    // wrong: 1727600000000
event_time: Math.floor(Date.now() / 1000) // right: 1727600000
Enter fullscreen mode Exit fullscreen mode

A quick check: a timestamp in seconds has 10 digits. Thirteen digits means milliseconds.

2. Customer data is hashed before it's formatted

Meta requires email, phone, name, city, state, zip and country to be SHA-256 hashed. What catches people out is that Meta matches on the hash of the formatted value, and hashing doesn't forgive formatting:

sha256('Buyer@Example.com ') !== sha256('buyer@example.com')
Enter fullscreen mode Exit fullscreen mode

Both are valid-looking 64-character hashes, so nothing flags the first one as wrong. Format first, then hash. Meta's rules:

Field Format before hashing
em Trim spaces, lowercase
ph Digits only, no leading zeros, country code included
fn, ln Lowercase, no punctuation
ct Lowercase, no punctuation, special characters or spaces
st Lowercase; US states as the 2-letter code
zp Lowercase, no spaces or dashes; first 5 digits for US zips
country Lowercase ISO 3166-1 alpha-2, such as gb

The UK phone trap. +44 (0)7700 900123 becomes 4407700900123 if you only strip symbols and leading zeros. The zero after the country code is a national prefix that isn't dialled internationally, so the number should be 447700900123. If your store sells in the UK, remove it before hashing.

3. Fields that must stay raw get hashed too

It's tempting to hash everything "to be safe". Meta says not to hash four fields:

  • client_ip_address
  • client_user_agent
  • fbc (the click ID cookie)
  • fbp (the browser ID cookie)

Meta uses these as they are. A hashed IP address is just a meaningless string.

4. Website events are missing the page URL or user agent

For events with action_source: "website", Meta requires both event_source_url and user_data.client_user_agent. Server-side setups often drop them, because the server never sees the browser. The fix is to capture both in the browser at checkout and pass them to your server with the order.

action_source itself is required on every event, as is user_data.

5. event_id is missing, or doesn't match the Pixel

Meta marks event_id as optional, but recommends it for deduplication. If the browser Pixel and your server both send the same purchase, give them the same ID so Meta can treat them as one event:

// Server
event_id: `order_${orderId}`

// Browser Pixel, same purchase
fbq('track', 'Purchase', { value, currency }, { eventID: `order_${orderId}` });
Enter fullscreen mode Exit fullscreen mode

Without a shared ID, you're relying on Meta to work out that two events are the same purchase.

6. Old events are retried in a batch

If any event in a request is more than 7 days old, Meta returns an error for the whole request and processes none of its events. A retry queue that keeps resending one stale event can take good events down with it. Drop anything older than 7 days before it reaches the batch.

A Purchase event that gets all six right

import { createHash } from 'node:crypto';

const sha256 = (v) => createHash('sha256').update(v).digest('hex');
const email = (v) => v.trim().toLowerCase();
const phone = (v) => v.replace(/[^0-9]/g, '').replace(/^0+/, ''); // must already include the country code

const event = {
  event_name: 'Purchase',
  event_time: Math.floor(Date.now() / 1000),       // 1. seconds
  event_id: `order_${order.id}`,                    // 5. same ID as the Pixel
  action_source: 'website',
  event_source_url: pageUrl,                        // 4. captured in the browser
  user_data: {
    em: [sha256(email(order.email))],               // 2. formatted, then hashed
    ph: [sha256(phone(order.phone))],
    client_ip_address: clientIp,                    // 3. raw
    client_user_agent: userAgent,                   // 3. raw, and 4. required
    fbp,                                            // 3. raw
    fbc,                                            // 3. raw
  },
  custom_data: {
    value: Number(order.total),                     // required for Purchase
    currency: order.currency,                       // required for Purchase
  },
};
Enter fullscreen mode Exit fullscreen mode

Check a payload in 10 seconds

I turned these checks into two free tools:

  • In the browser: paste an event into the Meta Conversions API payload validator. It runs locally, so nothing you paste is sent anywhere, and it has a formatter that applies Meta's rules and hashes the result.
  • In the terminal or CI: the open-source shopify-capi-validator package runs the same checks and exits with code 1 on any failure, so it can block a bad deploy.
npx shopify-capi-validator --payload ./event.json
Enter fullscreen mode Exit fullscreen mode

If you're sending Shopify orders server-side for the first time, the server-side tracking setup guide covers the whole flow, and the EMQ score estimator shows what the customer data you send means for match quality.

Top comments (0)