Meta CAPI deduplication with event_id: browser pixel + server events without double counting
By Marc Stevens · October 14, 2026
Meta recommends running the browser Pixel and the Conversions API (CAPI) side by side. The Pixel catches browser context; the server catches what blockers and expired cookies drop. Run both carelessly, though, and one demo booking turns into two conversions. Your cost per lead looks half what it really is, and Meta optimizes toward bad data. This guide explains how Meta's deduplication works, as described in its own developer docs, and how to wire it up for a B2B SaaS funnel.
If you're still deciding whether to add server-side events at all, start with server-side vs browser pixel tracking.
Why double counting happens
When someone books a demo, your confirmation page fires fbq('track', 'Schedule'). At the same moment, your backend (or your scheduling webhook) sends a Schedule event through CAPI. Meta now has two reports of one action. Unless it can tell they're the same, it counts both.
Meta can't guess. It needs a shared key that both reports carry. That key is the event ID.
How Meta deduplicates
From Meta's Handling Duplicate Pixel and Conversions API Events page, the main rule is simple. For a browser event and a server event to be treated as duplicates:
- The Pixel's
eventIDmust match the Conversions API'sevent_id. - The Pixel's event name must match the Conversions API's
event_name.
Meta says that when it finds the same combination from the same Pixel ID within 48 hours, it discards the later events. It also notes that events are only deduplicated if they are received within 48 hours of when it receives the first event with a given event_id. When matching events differ meaningfully in content, Meta says it generally prefers the one received first.
The server event parameters reference adds that event_id is technically optional but recommended for deduplication, and can be any unique string you choose. Meta gives an order number or transaction ID as examples.
The fbp / external_id fallback
Meta also describes a second approach: send event_name plus fbp and/or external_id consistently from both browser and server, and Meta compares those combinations. The docs list real limits. It generally only works when the browser event arrives first and the server event second; a server event is not discarded if no matching browser event arrived in the past 48 hours, even if one arrives later. It also does not deduplicate within one source: two identical browser events, or two identical server events, are both kept.
So treat fbp and external_id as useful matching signals, and rely on event_id for dedup. (Per Meta's customer information parameters, fbp is sent unhashed and external_id hashing is recommended. server-track-lab handles that for you.)
Generate one event_id and share it
The whole trick is that the ID is created once, at the moment of the conversion, and then given to both sides. Two patterns work well:
1. Render it into the page. When your server handles the demo booking, it creates an ID (for example demo_ plus the booking's database ID, or a UUID), saves it with the booking, sends the server event, and renders the same ID into the confirmation page for the Pixel.
2. Return it from your API. In a single-page app, the booking form posts to your backend. The backend creates the ID, sends the server event, and returns {"event_id": "demo_8f3a2c71"} in its response. The front end fires the Pixel with that value.
Don't generate the ID separately in the browser and on the server, and don't use timestamps that differ by a millisecond. Prefer something tied to the record itself, like the booking or signup ID, so a retry produces the same value.
Match the event_name, too
The ID alone isn't enough; the names must match. server-track-lab takes B2B event names and maps them to Meta's names before forwarding. Your Pixel must use the Meta-side name:
| You send to /api/v1/track | Meta event_name | Pixel call |
|---|---|---|
signup | CompleteRegistration | fbq('track', 'CompleteRegistration', …) |
lead | Lead | fbq('track', 'Lead', …) |
trial_started | StartTrial | fbq('track', 'StartTrial', …) |
demo_booked | Schedule | fbq('track', 'Schedule', …) |
contact | Contact | fbq('track', 'Contact', …) |
subscribed | Subscribe | fbq('track', 'Subscribe', …) |
Events like qualified, opportunity and closed_won happen in your CRM, so there's usually no Pixel event to dedup against. Send them server-side only.
The code
In the browser, the event ID goes in the fourth argument of the fbq track call, exactly as Meta's docs show:
<script>
// event_id rendered by your server, or returned by your booking API
var eventId = "demo_8f3a2c71";
fbq('track', 'Schedule', {}, {eventID: eventId});
</script>
On your server, send the same ID to server-track-lab with the B2B name demo_booked:
curl https://server-track-lab.pages.dev/api/v1/track \
-H "Authorization: Bearer $SST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_name": "demo_booked",
"event_id": "demo_8f3a2c71",
"timestamp": "2026-10-14T09:15:00-04:00",
"action_source": "website",
"event_source_url": "https://acme.example/book-a-demo",
"user": {
"email": "jane.doe@acme.example",
"external_id": "user_4821",
"fbp": "fb.1.1760440000000.1234567890",
"ip": "203.0.113.7",
"user_agent": "Mozilla/5.0"
}
}'
server-track-lab hashes the email and external_id, maps demo_booked to Schedule, and forwards event_id unchanged. It also keeps its own 72-hour dedup per workspace: send the same request again and you get "status": "deduplicated" with no second send to Meta and no second event on your quota. That protects you from your own retries; Meta's dedup protects you from the browser/server pair.
Common mistakes
- Different IDs on each side. A UUID created in the browser and another created on the server will never match. Create it once.
- Mismatched names. Pixel sends
Lead, server sendsSchedule, or a custom name with different capitalization. Use the mapping table above. - Reusing one ID for different actions. If every signup gets
event_id: "signup", Meta will treat real, separate conversions as duplicates. Each action needs its own unique ID. - Sending too late. Meta's server event docs say
event_timecan be up to 7 days before you send it; if any event in a request is older than that, Meta returns an error for the whole request. And dedup only applies within the 48-hour window above, so a server event sent days after the Pixel event won't be matched. - Leaving test codes in production.
test_event_codeis for checking events in the Test Events tool (see Meta's main body parameters). In server-track-lab, it's only attached when you send"test": true, using the code saved in your dashboard. Drop"test": truewhen you go live.
How to verify
- Preview the payload. Paste your event into the playground. It shows the exact Meta body, including the mapped
event_nameand yourevent_id, without storing anything. - Check our dedup. Send the same curl twice with your API key. The first returns
accepted, the seconddeduplicated. - Check Meta. Save your Test Events code in the dashboard, send with
"test": true, and fire the Pixel on the same page. In Events Manager, Test Events shows browser and server events as they arrive. Meta's Verifying Your Setup page explains how to check deduplication there.
FAQ
Is event_id required for Meta Conversions API?
Meta marks event_id as optional, but recommends it for deduplication. If you also run the Meta Pixel for the same conversion, send it.
How long does Meta wait to deduplicate a browser and server event?
Meta's documentation says events are only deduplicated if they are received within 48 hours of the first event with a given event_id.
Can I deduplicate with fbp or external_id instead of event_id?
Meta describes an fbp and/or external_id approach, but says it generally only works when the browser event arrives first, and it does not deduplicate within a single source. event_id plus event_name is the more reliable method.
Does server-track-lab send event_id to Meta?
Yes. The event_id you send to POST /api/v1/track is forwarded to Meta unchanged, and server-track-lab also ignores repeats of the same event_id in your workspace for 72 hours.
Try it
See the exact payload in the playground, no signup needed. Ready to send real events? Get a free API key: 1,000 events a month free, then CA$2 per 1,000 events. Moving from another tool? Read migrating from Stape or Cometly.