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
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')
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_addressclient_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}` });
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
},
};
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
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)