Open source
One event_id across the browser and your server
ad-events is a small TypeScript library we extracted from LoomaScale's own tracking. It mints one deduplication id per conversion and hands the same value to the Meta Pixel, Google Ads, the ChatGPT Ads pixel and their server-side Conversions APIs — so a conversion reported twice is counted once.
- Zero dependencies
- TypeScript, ESM and CJS
- Node, Bun, Deno and Edge
Why conversions get counted twice
Browser-only tracking loses a fifth to a third of conversions to ad blockers and browser privacy limits, so the usual fix is to send the same conversions again from the server. Do that without a shared id and every purchase is reported twice: your reported revenue is inflated, your cost per conversion looks better than it is, and automated bidding optimises toward a number that never existed.
Every platform supports the fix, and every platform spells it differently. Meta wants eventID in the fourth argument of the pixel call and event_id inside the server event. The ChatGPT Ads pixel wants event_id in the options argument and its money in integer cents rather than decimals. Google Ads has no browser-and-server pair at all — it deduplicates on transaction_id instead. Get one of them wrong and nothing fails: you find out weeks later, from the spend.
How the library works
One call reports a conversion to every platform you configured, and returns the id it used so you can forward it to your own backend in the same click handler.
1The browser mints the id
It has to be the browser, because the browser is the side that might not report at all. Mint it where the click happens and the server event stays deduplicable even when a blocker eats the pixel — which is the exact case you added the server event for.
2Every adapter gets the same value
The Meta, Google Ads and ChatGPT Ads adapters each put it where their platform expects it, and convert the money to the unit their platform expects. An adapter whose pixel never loaded is a no-op, never an exception, so a failed report can never break a checkout.
3The server replays it
When the payment webhook fires — the only real proof money moved — the server sends the same event name and the same id. The platform recognises the pair and keeps one conversion.
import {
createTracker,
metaAdapter,
googleAdsAdapter,
openAiAdapter,
} from "ad-events/browser";
const tracker = createTracker({
adapters: [
metaAdapter({ eventNames: { checkout_started: "InitiateCheckout" } }),
googleAdsAdapter({
conversionId: "AW-123456789",
labels: { checkout_started: "abcDEF_ghi" },
}),
openAiAdapter(),
],
});
// One id, every platform — and it is the return value, so you
// cannot forget to send it to your own backend.
const eventId = tracker.track({
name: "checkout_started",
value: 49,
currency: "USD",
});
await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ plan, price, eventId }),
});import { createMetaConversionsApi } from "ad-events/server";
const meta = createMetaConversionsApi({
pixelId: process.env.META_PIXEL_ID,
accessToken: process.env.META_ACCESS_TOKEN,
});
await meta.send({
eventName: "InitiateCheckout", // identical to the browser event
eventId, // the id the browser minted
email: order.email, // normalized and hashed for you
fbp, fbc, // the cookies the browser read
customData: { value: order.price, currency: "USD" },
});What it covers
Meta Pixel and Conversions API
Shared event_id, the _fbp and _fbc cookies read in the browser for the server event to carry, and identity fields normalized and SHA-256 hashed the way Meta hashes them when matching. Your own user id is hashed before it is sent, never raw.
Google Ads
Conversions through gtag with transaction_id deduplication, a synchronous non-reversible key so the same signup reported from two return pages counts once, and the gclid, gbraid and wbraid click ids handled as one value instead of three nullable columns.
ChatGPT Ads (OpenAI)
The oaiq pixel and the OpenAI Conversions API, including the two things that are easy to get wrong: money as integer cents, and a payload type that does not follow from the event name. There is almost no public tooling for this platform yet.
First-touch attribution, because the funnel outlives the URL
An ad lands on a URL carrying utm parameters and a click id. The visitor then navigates three times and pays two days later, by which point those parameters are long gone — and a payment provider's return URL will happily overwrite them with its own.
The library snapshots them once, keeps the first touch, and reads them back when the conversion finally happens. It also answers the question a cold-traffic funnel actually asks: did this visitor arrive from a paid ad? Paid social needs both a source and a medium to match; a Google click id is proof on its own, because Google only appends one to a click it charged for.
Questions
Does ad-events replace Google Tag Manager?
No. It replaces the hand-written glue you would otherwise put inside a tag manager or your application to keep the ids consistent across platforms. You still load your own pixel snippets.
Why must the browser mint the id rather than the server?
Because the browser is the side that might not report at all. If the server mints the id, a blocked pixel means the browser event never arrives and there is nothing to deduplicate against. Minting it in the click handler and forwarding it keeps the pair intact in exactly the case that motivated server-side tracking.
My Meta events still are not deduplicating. What should I check?
Four things, in order: the event name is identical on both sides, the event id is identical rather than merely present on both, the two events are within seconds of each other, and both declare the website action source. Events Manager has a Test Events tab that shows you which half arrived.
Does it work on Cloudflare Workers or Vercel Edge?
Yes. The server entry point uses only fetch and WebCrypto, with no Node built-ins, so it runs unchanged on Node, Bun, Deno and edge runtimes.
Does the library send data anywhere except the ad platforms?
No. There is no telemetry and no hosted service. The only network calls it makes are the ones you configure to the platforms you chose.
Built for our own funnel first
LoomaScale runs Google Ads and Meta Ads from ChatGPT and Claude. This library is the measurement layer underneath that product, published because the deduplication problem it solves is one every advertiser hits.
See what LoomaScale does