Destinations & match quality
Tocadule receives your events server-side, then forwards the ones you choose to ad and analytics destinations — Meta Conversions API, TikTok Events API, GA4 Measurement Protocol — from our servers, not the browser. Because the send happens server-side, it survives ad blockers, Safari ITP, and iOS ATT that silently drop the browser pixel.
This page explains how forwarding is wired, how the advertising-consent lane gates it, and how the identity you attach to an event drives match quality (Meta's EMQ score).
The install
The snippet is a tiny async loader. A stub defines tocadule.track / identify / cart / requestPush immediately and queues calls into tocadule._q; the real snippet.js loads asynchronously and drains the queue. Nothing you call is lost while the script is still loading.
<script>window.tocadule=window.tocadule||{};window.tocadule._q=window.tocadule._q||[];['track','identify','cart','requestPush'].forEach(function(m){window.tocadule[m]=window.tocadule[m]||function(){window.tocadule._q.push([m,arguments])}});</script>
<script async data-workspace="YOUR_WORKSPACE_ID" src="https://app.tocadule.com/snippet.js"></script>The four calls you have:
| Call | Purpose |
|---|---|
tocadule.track(name, props) | Record any event (standard or custom). |
tocadule.cart(data) | Send the whole current cart (PII-free). |
tocadule.identify(email, traits) | One-time richer profile at sign-up / login. |
tocadule.requestPush(cb) | Ask for web-push permission. |
How an event becomes a forward
- You call
tocadule.track('purchase', {...}). The snippet POSTs it to/events/track. - The event is stored, its
anonymous_idis resolved to a contact viaidentity_map, and geo / IP / User-Agent are enriched onto it. - Tocadule looks at your destinations. Each destination has an
events_to_forwardlist — an exact, one-to-one mapping of your event names to platform event names (e.g.purchase → Purchase). There is no alias guessing: a destination forwards an event only if a rule names it exactly. - Matching destinations are enqueued to a worker, which builds each platform's payload and sends it.
Forwarding is opt-in per event, per destination. An event you don't map to a destination is stored and available in analytics but never leaves Tocadule. Events flagged as bot traffic are never forwarded.
Consent: two independent lanes
Tocadule reads two consent lanes from your CMP (Consent Mode v2, IAB TCF v2, OneTrust, Cookiebot, Iubenda, GPC, or an explicit override) and stamps both onto every event:
| Lane | Field | Gates |
|---|---|---|
| Analytics / general | consent_state | Storing anonymous_id + PII. We store unless it is explicitly denied. |
| Advertising | ad_consent | Whether an event is forwarded to ad destinations, and how much of it. |
Ad forwarding reads the advertising lane only. A visitor who granted analytics but denied ads is not ad-forwarded — the analytics lane never stands in for the ad lane.
Email / SMS opt-in (subscribed_email / subscribed_sms) is a separate merchant preference set through identify() or your preference center — not a CMP lane.
What the ad lane decides
The advertising lane resolves to one of four treatments before anything is sent:
ad_consent | Treatment | What is sent |
|---|---|---|
granted | full | Hashed email/phone/name + fbp / fbc / external_id + aggregate geo + IP/UA. Maximum match. |
unknown (workspace = permissive) | full or ldu | Merchant's default button. ldu strips PII but keeps click IDs (attribution still works). |
unknown (workspace = cookieless) | modeled | Non-PII, non-click. Aggregate signal only, with the platform's limited-use flag. |
denied | modeled | Same as above — modelled, not attributed to a person. |
unknown / null (legacy events, or the snippet not yet reporting the lane) defers to your workspace posture. For Meta, ldu and modeled both attach the LDU (Limited Data Use) flag so Meta measures without targeting; modeled additionally drops the click ID so Meta models the conversion instead of attributing an individual.
What lands in Meta's user_data (match keys)
For a Meta CAPI forward, Tocadule assembles user_data from the resolved contact + the visitor's stored browser identifiers + the request context. Match quality (EMQ) is a function of how many of these fields are present and correct.
| Meta field | Source | Sent in |
|---|---|---|
em | Contact email (SHA-256, lower-cased/trimmed) | full only |
ph | Contact phone (digits-only, SHA-256) | full only |
fn / ln | Contact first / last name (SHA-256) | full only |
fbc | Real _fbc cookie the snippet captured, else reconstructed from stored fbclid + click time (ms) | full, ldu |
fbp | _fbp browser ID (read from the pixel's cookie or baked by the snippet) | full, ldu |
external_id | The visitor's anonymous_id | full, ldu |
country / st / zp / ct | Aggregate geo (ISO country, region, postal, city — SHA-256) | full, ldu, modeled |
client_ip_address | Real client IP (from the request) | always |
client_user_agent | Request User-Agent | always |
Notes grounded in the adapter:
- Everything personal is hashed with SHA-256 before it leaves; Tocadule never sends a raw email, phone, or name.
- Aggregate geo,
fbp,fbc, andexternal_idare not treated as PII — they are sent even underldu. Only the truly-personalem/ph/fn/lnare stripped outsidefull. - Geo comes from the event, resolved from the real client IP by MaxMind (offline). It is not a contact-profile field — contact location is declared-only, set from
identify()traits.
Dedup with your browser pixel
If the snippet is also present alongside your own Meta pixel, it injects the same event_id into the pixel's eventID and sends it on the CAPI copy, so Meta dedups the two into one event (web_event_id → source_event_id → row id, in that order). You keep your pixel; the server-side send fills the gap when the browser pixel is blocked, deduped rather than double-counted. You never set dedup IDs yourself — anonymous_id handles browser-side dedup and source_event_id handles external systems.
What lands in Google Ads
Google Ads receives conversions only: each event you forward must be mapped to a conversion action of type Import from clicks in your Google Ads account. Tocadule sends one identifier from this list, strongest first, plus everything else your consent allows:
| Sent as | What it uses | Needs |
|---|---|---|
gclid | The Google click ID | full or ldu consent |
gbraid / wbraid | The iOS click ID, when gclid was removed (common on Safari) | full or ldu consent |
enhanced | Hashed email / phone / name / address of the contact, with no click ID | full consent |
device | Google's landing context (g_session_attrs) and, with full consent, the landing IP address | full or ldu consent |
- Click IDs and the landing device (browser and IP) come from the visitor's most recent Google click, stored server-side, so a conversion on a later page or another device still carries them.
- No IP address is sent for visitors in the EEA, the UK or Switzerland.
- Under
ldu, Tocadule sends only click IDs and Google's landing context, and marks the conversion as consent denied. Undermodelednothing is sent, because Google Ads accepts no upload without an identifier.
Uploads are held, then checked
Google rejects a conversion whose click is less than 6 hours old, and it reports the outcome of an upload later, not in the response. So Google Ads forwards do not go out the moment the event arrives:
- The conversion waits until its click is 6 hours old (a conversion with no click ID goes in the next run).
- Every 15 minutes, waiting conversions are uploaded in batches.
- About 30 minutes later, Tocadule asks Google for the result: confirmed, partly rejected or rejected. A failed upload is retried up to 3 times.
- If Google has not answered after 26 hours, the conversion is closed as no result. A click older than 90 days is never uploaded.
The destination's Overview tab shows where each conversion of the last 7 days stands, when the next upload goes out, and Google's latest error messages.
What lands in ChatGPT Ads
ChatGPT Ads (OpenAI) receives events through its Conversions API. To connect, paste two values from ChatGPT Ads Manager → Conversions: the data source's Pixel ID and a Conversions API key (not the Ads API key).
Events alone are not conversions. In Ads Manager, create a conversion event from this data source (for example Purchase) and attach it to your campaigns. Until then, events arrive but no campaign reports them. Only a standard event can be a campaign's optimization goal; custom events are reported only.
| Our event | Sent as |
|---|---|
purchase | order_created |
checkout_started | checkout_started |
add_to_cart | items_added |
product_view | contents_viewed |
| anything else you add | a custom event under its own name, or any standard event you pick |
page_view is not forwarded by default — add it if a campaign needs it.
| Treatment | What is sent |
|---|---|
| full | The ad click (oppref), the OpenAI pixel's browser id (obref), hashed email / phone / name, a hashed external_id, aggregate geo, IP and user agent |
| ldu | oppref, obref, aggregate geo, IP and user agent — no personal data — and opt_out, so OpenAI does not use the event for personalization |
| modeled | Nothing. ChatGPT Ads has no consent-denied mode, so a refusing visitor is not sent (the same rule as Google Ads) |
opprefis the click id ChatGPT adds to an ad's landing URL. OpenAI's API does not capture it; the snippet keeps it for 30 days (the same lifetime OpenAI's own pixel uses), so a purchase days later — or a webhook order with no browser at all — still carries the click. Visits that arrive with it are classified astraffic_source: paid,traffic_platform: chatgpt.- Amounts are sent in the currency's minor unit (₺129.99 →
12999). - Events older than 7 days are not sent; ChatGPT Ads rejects them.
- Dedup with the OpenAI pixel: if you run OpenAI's pixel (
oaiq), the snippet gives itsmeasurecalls the same event id our server copy carries. OpenAI keeps whichever copy arrives first and drops the other. - Test fire checks a full test purchase field by field without saving it, then delivers one test page view to prove the connection. Neither is counted as a conversion unless you made page views one.
Why email on high-intent events matters most
The single biggest lever on match quality is a hashed email on the event. It only ships in full mode (granted ad consent), and it only exists if the event resolved to a contact.
You do not need to call identify() first. You can put email, phone, first_name, last_name, or external_id on any event:
- If the visitor isn't known yet, Tocadule auto-creates the contact and links it to their
anonymous_id. - If they're known, it enriches the existing contact.
So a logged-in shopper's purchase or checkout_started that carries their email forwards to Meta / TikTok / Google as a strong match instead of an anonymous hit. Purchase and checkout are exactly the events ad platforms optimise toward, so that is where identity pays off most.
Two linking paths per visitor:
identify()once — links and backfills the visitor's prior anonymous events onto the contact.- Email on events — links from that event forward only (does not backfill earlier events).
A visitor needs at least one linking signal, or they stay anonymous and forward with only IP + UA.
The high-intent events, verbatim
Put the shopper's email on the event whenever they're logged in. Guard every token with sub(...) so an un-replaced {token} never sends junk.
<script>
// Put this on your order-success / thank-you page. Replace the {…} tokens with
// YOUR platform's real order variables (any platform — these are placeholders).
// order_id + revenue are REQUIRED. The event only fires once BOTH are real, so
// an un-replaced template never sends a junk order.
// items are OPTIONAL — we fill them from the cart automatically.
// email/phone/name are OPTIONAL but STRONGLY recommended — they auto-link the
// contact and give the purchase the highest match quality (this is the event ad
// platforms optimise toward). shipping / tax / coupon are optional extras.
if (window.tocadule) {
var sub = function(v){ return v && String(v).indexOf('{') < 0 ? v : undefined; };
var num = function(v){ var n = parseFloat(v); return isFinite(n) ? n : 0; };
var orderId = sub('{orderId}');
var revenue = num('{orderTotal}');
if (orderId && revenue > 0) {
tocadule.track('purchase', {
order_id: orderId,
revenue: revenue,
currency: sub('{currency}') || 'TRY',
email: sub('{email}'),
phone: sub('{phone}'),
first_name: sub('{firstName}'),
last_name: sub('{lastName}'),
shipping: num('{shippingTotal}'),
tax: num('{taxTotal}'),
coupon: sub('{couponCode}')
});
}
}
</script><script>
if (window.tocadule) {
var sub = function(v){ return v && String(v).indexOf('{') < 0 ? v : undefined; };
tocadule.track('checkout_started', {
cart_total: parseFloat('{total}') || 0,
items_count: parseInt('{totalProduct}') || 0,
currency: 'TRY',
// Identifiers on the event auto-create/link the contact — no separate identify.
email: sub('{email}'),
phone: sub('{phone}'),
first_name: sub('{firstName}'),
last_name: sub('{lastName}')
});
}
</script>The cart is deliberately PII-free
tocadule.cart(...) carries only cart contents — never an email. It rides on the visitor's anonymous_id and resolves to the contact through the same identity_map join once any other event links them. Keep it lean.
<script>
// Send your WHOLE current cart on EVERY change (add / remove / quantity).
// Build items[] by looping over every line in the cart — one object per line.
// The values below are EXAMPLES — replace them with your real cart data.
// REQUIRED per item: product_id, name, price (number), quantity (number)
// OPTIONAL per item: image, url (make the cart cards look nice)
if (window.tocadule) {
tocadule.cart({
items: [
{ product_id: '101', name: 'Blue T-Shirt', price: 199.90, quantity: 2, image: 'https://yourshop.com/img/blue.jpg', url: 'https://yourshop.com/blue-tshirt' },
{ product_id: '250', name: 'Black Cap', price: 149.00, quantity: 1, image: 'https://yourshop.com/img/cap.jpg', url: 'https://yourshop.com/black-cap' }
// …repeat one object for EACH product currently in the cart
],
total: 549.80, // cart subtotal as a number
currency: 'TRY',
item_count: 3 // total quantity across all lines
// No email / PII here on purpose — the cart stays lean. Once any other event
// carries the shopper's email (purchase, or a logged-in product_view /
// checkout), we link the contact and the cart resolves off the anonymous_id.
});
}
</script>Custom event names
Any event name is accepted — you are not limited to the standard set. An unknown name is stored and appears in your dashboard exactly like a standard event (it shows up in Events / analytics under the name you sent, and in a contact's timeline once linked).
For forwarding, a custom name still needs an exact rule in a destination's mapping. When a rule's meta_name is set, it forwards under that Meta standard name (getting Meta's algorithm preference); when it's left null, the event is forwarded to Meta as a Custom event under your name verbatim — still measured and retargetable, just without standard-event optimisation.
Measuring match quality (Meta EMQ)
EMQ (Event Match Quality) is Meta's 0–10 score of how well your CAPI events match real accounts. Meta exposes it as a rolling 24–48h window with no history, so Tocadule snapshots it daily (per event name) and captures a day-0 baseline the moment a Meta destination is connected — the only "before Tocadule" reference you'll ever get, since it can't be backfilled. Send warnings Meta returns alongside a successful send are recorded too.
To move the score up: get a granted ad consent so full mode unlocks the hashed email/phone/name, and attach the shopper's email to your high-intent events. IP + UA alone lands around 3/10; adding email, fbp, and fbc is what pushes it higher.
TikTok has no public match-quality API and GA4 has no EMQ concept (its health story is reconciliation), so the EMQ dashboard is Meta-only. The identity you attach still benefits the TikTok and GA4 forwards — richer match keys help every destination.