Architecture

Which of the 10 channels gets which event

One event enters Beaconry's dispatcher. Ten channels are watching. None of them gets every event. GA4 wants the full funnel, Meta wants conversions plus a couple of mid-funnel signals, X Ads wants only the five conversion types you pre-configured. The routing is not ad-hoc per dispatch method, it is two small data structures in one file. This is the whole map, verified against the source.

Reading time: ~9 minPublished: 2026-06-08

The problem routing solves

Every conversion API has its own vocabulary of standard events. Meta knows Purchase and AddToCart. TikTok knows Purchase and InitiateCheckout. Pinterest knows checkout and view_content. They overlap a lot, but not completely, and the gaps are where naive tracking setups quietly fall apart.

Two failure modes show up if you fan out blindly. First, double-counting: a WooCommerce shop page that lists products fires both view_item_list and view_item, and at Meta both of those map to the same event name (ViewContent). Send both and Meta receives two ViewContent events with different event_ids for a single page view. No dedup fires (different ids), so the reporting counter and Smart-Bidding both double-count. Second, custom-event noise: send Meta a scroll event and there is no standard name for it, so it lands as a generic Custom Event that pollutes the Events Manager and carries zero optimisation value.

So the goal is precise: send each platform exactly the events it has a real, optimisable home for, and nothing else.

Two data structures, one file

All routing lives in class-bcnr-forwarder.php. There is no per-channel routing scattered across the dispatch methods. Two constants carry the decision, and the central dispatch() loop reads them.

1. ANALYTICS_ONLY_EVENTS: behavioural signals that are never a real ad conversion.

private const ANALYTICS_ONLY_EVENTS = [
    'form_start',
    'form_abandon',
];

2. SKIP_BY_CHANNEL: a per-channel list of GA4 event names that channel should not receive, each entry annotated with the reason. Meta's list, for example, includes view_item_list, user_engagement, refund, and the engagement signals (scroll, video_start, file_download, click).

The dispatch loop is then almost boring to read, which is the point:

// Central internal-vs-ad-vendor gate.
if ( in_array( $event['name'], self::ANALYTICS_ONLY_EVENTS, true ) ) {
    if ( self::has_ga4() ) {
        self::dispatch_ga4( $event );
        self::after_dispatch( 'ga4', $event );
    }
    return; // hard-stop before any ad channel
}

if ( self::has_ga4() ) { /* GA4 gets everything */ }

if ( self::has_meta() && self::should_dispatch_to_channel( 'meta', $event ) ) {
    self::dispatch_meta( $event );
    self::after_dispatch( 'meta', $event );
}
// ...same shape for tiktok, pinterest, x_ads, snapchat, reddit

should_dispatch_to_channel() is the gatekeeper for the browser-pixel-capable channels. It checks the channel's SKIP_BY_CHANNEL set, honours a per-form name_<channel> override (a lead form can opt a normally-skipped event back in), and applies one hybrid-mode rule for user_engagement (more on that below).

The commerce matrix

Here is the full routing for the WooCommerce 10-event funnel. A cell shows the event name actually sent on the wire, or "skip" when the channel does not receive it. Columns are the seven channels that route by event name. The three OAuth-broker channels (LinkedIn, Google Ads, Microsoft Ads) work differently and get their own section.

GA4 eventGA4MetaTikTokPinterestSnapchatRedditX Ads
view_itemViewContentViewContentview_contentVIEW_CONTENTVIEW_CONTENTskip
view_item_listskipskipview_categoryskipskipskip
view_cartViewCart (custom)skipskipskipskipskip
add_to_cartAddToCartAddToCartadd_to_cartADD_CARTADD_TO_CARTaddtocart
remove_from_cartRemoveFromCart (custom)skipskipskipskipskip
searchSearchSearchsearchSEARCHSEARCHskip
begin_checkoutInitiateCheckoutInitiateCheckoutinitiate_checkoutSTART_CHECKOUTCUSTOMskip
add_payment_infoAddPaymentInfoAddPaymentInfoadd_payment_infoADD_BILLINGCUSTOMskip
purchasePurchasePurchasecheckoutPURCHASEPURCHASEpurchase
refundskipskipskipskipskipskip

A few cells deserve the why, because the why is the whole design.

view_item_list: skipped almost everywhere

Only GA4 and Pinterest receive it. GA4 has a real view_item_list event. Pinterest has a distinct view_category event, so it maps cleanly. Everyone else (Meta, TikTok, Snap, Reddit) maps it to the same name as view_item (ViewContent / VIEW_CONTENT), which means a single shop page that previews a product would fire two same-named events with different event_ids. That is the double-count trap, so view_item_list is in the skip set for all four.

view_cart and remove_from_cart: Meta-custom, otherwise dropped

Neither has a standard event at most platforms. Meta accepts them as named custom events (ViewCart, RemoveFromCart) because a custom Meta event still shows cleanly in the Events Manager and can be used for audience building. At TikTok, Pinterest, Snap, and Reddit they are skipped: their custom-event fallbacks would either collide with a reserved slot (Snap's CUSTOM_EVENT_1 is reserved for form leads) or get silently swallowed (TikTok drops unmapped names), so the event would cost bytes and buy nothing. Smart-Bidding optimises on AddToCart and Purchase, not on cart-removes.

begin_checkout to Reddit: a real custom event, on purpose

Reddit's standard vocabulary has no checkout-initiation event. Rather than skip it, Beaconry lets it fall through to Reddit's CUSTOM tracking_type with custom_event_name: 'begin_checkout'. That is a deliberate choice, not an oversight: a named custom event at Reddit is still a usable, reportable signal, unlike the swallowed-on-arrival fate the same event would meet at TikTok. The general rule is "skip what would be noise, keep what is a usable custom event", and the line falls in a different place per platform because each platform handles unmapped names differently.

X Ads: conversions only

X (Twitter) Ads requires a pre-configured conversion_event_id from its Events Manager for each event type. There is no generic event stream to push mid-funnel signals into. So X's effective map is just five slots (purchase, add_to_cart, lead, signup, pageview), and the rest of the funnel (view_item, view_cart, search, begin_checkout, add_payment_info) simply has no slot to go to. The engagement signals are in its skip set for the same reason.

refund: GA4 and nowhere else

Refund is the cleanest example of "send each platform what it can use", because the answer for nine of the ten channels is "nothing".

GA4 has a first-class refund event that nets revenue back against the original purchase, so refund reporting actually works: a refunded order reduces your reported revenue. That is genuine analytics value, so refund goes to GA4.

No ad platform has a refund event that does anything useful. Meta is explicit: refund is not a supported Conversions API event type (Meta's refund handling lives in the separate Commerce-Platform order-management API, not CAPI). TikTok's Events API 2.0 has no refund standard event. Pinterest, Snapchat, and Reddit have no native refund event either. You could force a custom "Refund" event onto each, but it would be a dead counter: no Smart-Bidding uses it, no revenue gets netted, and at Snap it would even collide with the lead slot. So every ad channel lists refund in its skip set. Refund is GA4-only, by design, verified against each vendor's current docs.

There is a longer write-up of this specific decision in Why refunds go to GA4 only. The short version: a refund event that nobody optimises on is not a feature, it is wire noise.

form_start and form_abandon: the central gate

Form-funnel signals are behavioural analytics, not ad conversions. form_start fires on first field focus, form_abandon fires when someone started a form, filled at least one field, and left without submitting. They tell you where a form loses people, which is useful for you, and useless (or worse, misleading) as a conversion signal at an ad platform.

These two are not handled by SKIP_BY_CHANNEL. They are handled one level up, by the ANALYTICS_ONLY_EVENTS gate at the very top of the dispatch loop. The moment the dispatcher sees one of these names, it sends to GA4 (if GA4 is even configured) and returns immediately, before the code path to any ad channel is reached.

The reason this is a separate, central gate and not just ten skip-set entries is robustness. With per-channel skip lists, adding an eleventh ad channel means remembering to add form_abandon to its skip list, and forgetting means a behavioural event silently leaks to an ad vendor as a custom conversion. With the central gate, a new channel inherits the rule for free: the hard-stop happens before its dispatch call exists in the loop. To classify a future behavioural event as analytics-only, you add its name to that one array and change nothing else.

There is a second gate stacked behind it for form_abandon specifically. By default an abandon never even reaches GA4: BCNR_Form_Funnel::observe() records it into the in-plugin funnel store and then cancels the dispatch entirely, unless you have opted into form_funnel_ga4. So out of the box, form_abandon touches nothing on the wire at all, and even when you opt in, it reaches GA4 only, never an ad vendor. The full drop-off-analysis story is in Form funnels: find the field that kills the conversion.

The three broker channels route differently

LinkedIn, Google Ads, and Microsoft Ads do not have a flat event-name vocabulary you map to. They route through conversion slots that the customer configures, so they are not in SKIP_BY_CHANNEL at all. They self-skip when the event does not map to one of their slots.

LinkedIn maps each GA4 event to one of five conversion-rule slots (purchase, lead, signup, addtocart, keypageview). Each slot only fires if the customer has pasted the matching Campaign-Manager ConversionRule URN into settings. No URN, no dispatch.

Google Ads and Microsoft Ads share a conversion-kind mapper, ads_conversion_kind(). It returns a slot name for exactly the events that are real Google/Microsoft conversion types (purchase, add_to_cart, begin_checkout, generate_lead, subscribe, schedule, sign_up, contact) and an empty string for everything else, which is a silent skip. So on the commerce funnel, Google and Microsoft Ads receive add_to_cart, begin_checkout, and purchase, and ignore the view and search events, because those are not configurable conversion actions in those platforms.

Both also need their click identifier to attribute (gclid for Google, msclkid for Microsoft). Microsoft silently skips any upload without an msclkid, because the OfflineConversions API rejects it anyway. The credentials never live in the plugin: the OAuth broker at oauth.beaconry.app holds the refresh tokens and developer tokens, and the plugin carries only an HMAC-signed site-bearer. That is what lets you connect Google and Microsoft Ads without waiting on a developer-token approval, which is its own story in Connecting Google Ads without the developer-token wait.

One subtlety: user_engagement and hybrid mode

user_engagement (fired after roughly 10 seconds of active time plus 50 percent scroll) is in the skip set for Meta and TikTok, but the skip is conditional. It maps to ViewContent at both platforms, and sending a server-side ViewContent only makes sense when the matching browser pixel is also running, because then both fire with the same event_id and the platform dedupes them into one enriched event. With the browser pixel off, a lone server-side ViewContent from engagement has no browser counterpart to merge with and just inflates the ViewContent counter. So should_dispatch_to_channel() lets user_engagement through only when that channel's hybrid browser pixel is enabled, and skips it otherwise. The event_id dedup mechanics behind that are covered in Hybrid mode and event_id dedup.

EDD and SureCart inherit the same routing

The routing matrix is keyed on GA4 event names, not on the commerce platform, so it is platform-agnostic by construction. Easy Digital Downloads fires the same funnel minus search (it has no product-search event), and SureCart fires purchase and refund server-side with its JS cart events arriving through a browser bridge. All of those events enter the exact same dispatch() loop and hit the exact same SKIP_BY_CHANNEL and slot logic. A SureCart add_to_cart routes to the same seven destinations as a WooCommerce add_to_cart, because the dispatcher does not know or care which adapter produced it.

How to verify the routing for your own setup

You do not have to trust this matrix in the abstract. Each platform's debug view is the ground truth:

  • GA4 Realtime / DebugView: you should see the full funnel, including view_item_list, refund, and (if enabled) form_start.
  • Meta Test Events: you should see ViewContent, AddToCart, Purchase and friends, but never a scroll or a refund. A ViewCart custom event is expected and correct.
  • TikTok / Pinterest / Snap / Reddit event managers: standard events only, no engagement noise. Reddit will show begin_checkout as a CUSTOM event, which is intended.
  • Beaconry Live-Conversions card: the in-plugin recorder shows the name actually sent per channel, so a row reading "Meta ViewContent" for what GA4 logged as user_engagement is the hybrid-dedup mapping working, not a bug.

If you see an event at a platform that the matrix says should be skipped, the first suspect is a per-form name_<channel> override opting it back in, which is the one sanctioned way to deviate from the defaults.

Take-away

Beaconry's routing is not a pile of conditionals spread across ten dispatch methods. It is one analytics-only gate plus one per-channel skip table, both in class-bcnr-forwarder.php, both annotated with the reason for every entry. The principle is consistent: a platform receives an event only when it has a standard name for it that drives optimisation, or a named custom event that is still usable, and it is skipped when the alternative would be a double-count, a swallowed event, or a dead counter. Refund proves the principle in the extreme (GA4 only, because no ad platform can do anything with it), and the form-funnel gate proves the robustness (behavioural signals can never leak to an ad vendor, including channels not added yet). Send each platform exactly what it can use, and nothing else.