Destinations & match quality

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:

CallPurpose
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

  1. You call tocadule.track('purchase', {...}). The snippet POSTs it to /events/track.
  2. The event is stored, its anonymous_id is resolved to a contact via identity_map, and geo / IP / User-Agent are enriched onto it.
  3. Tocadule looks at your destinations. Each destination has an events_to_forward list — 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.
  4. 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:

LaneFieldGates
Analytics / generalconsent_stateStoring anonymous_id + PII. We store unless it is explicitly denied.
Advertisingad_consentWhether 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_consentTreatmentWhat is sent
grantedfullHashed email/phone/name + fbp / fbc / external_id + aggregate geo + IP/UA. Maximum match.
unknown (workspace = permissive)full or lduMerchant's default button. ldu strips PII but keeps click IDs (attribution still works).
unknown (workspace = cookieless)modeledNon-PII, non-click. Aggregate signal only, with the platform's limited-use flag.
deniedmodeledSame 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 fieldSourceSent in
emContact email (SHA-256, lower-cased/trimmed)full only
phContact phone (digits-only, SHA-256)full only
fn / lnContact first / last name (SHA-256)full only
fbcReal _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_idThe visitor's anonymous_idfull, ldu
country / st / zp / ctAggregate geo (ISO country, region, postal, city — SHA-256)full, ldu, modeled
client_ip_addressReal client IP (from the request)always
client_user_agentRequest User-Agentalways

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, and external_id are not treated as PII — they are sent even under ldu. Only the truly-personal em/ph/fn/ln are stripped outside full.
  • 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 asWhat it usesNeeds
gclidThe Google click IDfull or ldu consent
gbraid / wbraidThe iOS click ID, when gclid was removed (common on Safari)full or ldu consent
enhancedHashed email / phone / name / address of the contact, with no click IDfull consent
deviceGoogle's landing context (g_session_attrs) and, with full consent, the landing IP addressfull 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. Under modeled nothing 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:

  1. The conversion waits until its click is 6 hours old (a conversion with no click ID goes in the next run).
  2. Every 15 minutes, waiting conversions are uploaded in batches.
  3. About 30 minutes later, Tocadule asks Google for the result: confirmed, partly rejected or rejected. A failed upload is retried up to 3 times.
  4. 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 eventSent as
purchaseorder_created
checkout_startedcheckout_started
add_to_cartitems_added
product_viewcontents_viewed
anything else you adda 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.

TreatmentWhat is sent
fullThe ad click (oppref), the OpenAI pixel's browser id (obref), hashed email / phone / name, a hashed external_id, aggregate geo, IP and user agent
lduoppref, obref, aggregate geo, IP and user agent — no personal data — and opt_out, so OpenAI does not use the event for personalization
modeledNothing. ChatGPT Ads has no consent-denied mode, so a refusing visitor is not sent (the same rule as Google Ads)
  • oppref is 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 as traffic_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 its measure calls 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:

  1. identify() once — links and backfills the visitor's prior anonymous events onto the contact.
  2. 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.